The script in my previous post (Plot DVHs With OxyPlot) is useful if you know exactly which structures you want to plot and they match the IDs you hard-coded. But if you ever want to explore different structure DVHs, you’d be out of luck. It would be nice to show the user the structures available in the plan, and let him or her decide which ones to show their DVH.
In this post, you’re going to write a new script that does just that. When you’re finished, the script will look something like this:
The source code for this script is in the DvhPlotWithOxyPlot repository on GitHub.
To accomplish this user interaction, you’re going to use Windows Presentation Foundation (WPF). This is a technology by Microsoft that lets you create advanced user interfaces declaratively, using an XML-like language called XAML. I’m not going to go into detail on WPF, but I will show you enough to create the script shown above.
For more information on WPF (and C# in general), you can read the relevant chapters in the
excellent book Pro C# 7 by Andrew Troelsen. For more practical examples using WPF, see
Windows Presentation Foundation 4.5 Cookbook by Pavel Yosifovich.
In addition to WPF, you will use the Model-View-ViewModel (MVVM) pattern and data-binding to connect to WPF controls, such as buttons and check boxes. In short, the MVVM pattern separates the view from the data (or model) that the view is supposed to display. So instead of changing the view directly, you change the view model, which is connected to the view via data-binding. Don’t worry if you don’t fully understand this concept, it’ll become clearer once you see some working code.
Just like you did in the previous post, start by creating a new class library project in Visual Studio to contain the binary plug-in script. Name the solution "DvhPlot" and the project "DvhPlot.Script." Rename your main script class to Script and use the following Execute method:
public void Execute (ScriptContext context, Window window)
{
var mainViewModel = new MainViewModel(context.PlanSetup);
var mainView = new MainView(mainViewModel);
window.Title = "DVH Plots";
window.Content = mainView;
}
It looks similar to the script in the previous post, except that now you first create the view model, then the view, and finally you assign the view to the window’s content. MainViewModel and MainView don’t exist yet, but you’ll create these soon. Before you do, however, remember to reference the same libraries as before, including ESAPI and the OxyPlot library.
Now, add a new WPF UserControl to the DvhPlot.Script project by right-clicking on the project, selecting Add and then New Item, and finally choosing "User Control (WPF)" (don’t choose "User Control" without the "(WPF)" as it is a different kind of item). Call this new user control "MainView." You should now have two new files in your project: MainView.xaml and MainView.xaml.cs. Open MainView.xaml and modify it to the following:
<UserControl
x:Class="DvhPlot.Script.MainView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:oxy="http://oxyplot.org/wpf"
>
<Grid Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition />
</Grid.ColumnDefinitions >
<ItemsControl
Grid.Column="0"
ItemsSource="{Binding Structures}"
>
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox Content="{Binding Id}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<oxy:PlotView
Grid.Column="1"
Model="{Binding PlotModel}"
/>
</Grid>
</UserControl>
As you can see, this code is not written in C#; it is written in XAML. The syntax is very similar to XML, where elements are placed inside angle brackets. These elements may have properties (like the Margin property for the Grid element), or may have nested elements (like the CheckBox element inside DataTemplate). Typically, XAML maps very closely to the physical layout of the user interface. For example, the PlotView element is inside the Grid, which itself is inside the UserControl. As a result, the view will display the PlotView laid out in a Grid panel within the bounds of the UserControl.
The UserControl represents a general user-defined control. If there’s nothing inside the control, nothing will be displayed. Therefore, one usually has to fill up the user control with other items. Typically, these items are arranged using a panel (or layout) control, such as the Grid panel you’ve defined above. Other panel controls include StackPanel, DockPanel, and WrapPanel.
The Grid panel lays out its child controls in a grid or table, arranged in rows and columns. In the code above, you can see that two columns have been defined (the grid automatically has one row). By default, the grid is evenly divided by the number of columns, so that each column has the same width. But by setting the column’s Width property to Auto, as has been done above, the column will only take up as much space as it needs. The remaining columns (in this case, just one) will fill up the rest of the available space.
The first element inside the Grid is the ItemsControl element. This is a control that shows its child elements in a list. Notice that the Grid.Column property has been set to 0. This means that the ItemsControl will appear in the first column (on the left) of the Grid panel (remember than indexes in C# start at 0, not 1).
The ItemsSource property of the ItemsControl defines the items to be displayed as a list. As was mentioned at the start of this post, the user will be presented with a list of structures to choose from. Therefore, the list needs to contain the structures of the opened plan. Here we use the magic of data-binding and simply assign this property to {Binding Structures}. The actual data is obtained and stored in the view model, which you’ll see shortly.
The structures shouldn’t just be presented as is; they need to be displayed as check boxes that the user can interact with. To change the way items inside an ItemsControl are displayed, you use a DataTemplate. The DataTemplate must be defined inside ItemsControl.ItemTemplate. The DataTemplate above contains a single CheckBox element. The check box’s content (that is, the text to be displayed) is data-bound to the structure’s Id property. Visually, you’ll see a check box and then the structure’s ID for every structure in the list.
The second element in the Grid is the PlotView. Notice that it’s defined with the oxy: prefix. This is because PlotView comes from an external library, so its namespace needs to be defined above (see the xmlns:oxy definition in the UserControl element). You can think of it as a using statement in XAML. The PlotView’s grid column is 1, so it will appear in the second column of the grid. The Model property, which provides the PlotView with everything it needs to draw its contents, is data-bound to the PlotModel property of the view model.
As you’ve seen, some of the XAML properties are data-bound to properties of the view model. You may be wondering how the view knows which view model to use. There’s no magic here. You need to manually set the view’s DataContext property to the view model it should use for data-binding. Open the MainView.xaml.cs file and modify it as follows:
public partial class MainView : UserControl
{
// Create dummy PlotView to force OxyPlot.Wpf to be loaded
private static readonly PlotView PlotView = new PlotView();
public MainView(MainViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
Ignore the PlotView static field for the moment. In the constructor, you pass in the view model. After the view initializes its components by calling the InitializeComponent method, you set the DataContext to the view model. (You don’t need to write the InitializeComponent method because it’s already been written for you as part of WPF.) Now, the static field PlotView is defined above to force the OxyPlot.Wpf library to be loaded at the right time (it’s kind of a bug that this is necessary, so I won’t explain this further).
It’s time to write the view model, so create a MainViewModel class and replace the corresponding source file’s contents with the following:
using System.Collections.Generic;
using OxyPlot;
using OxyPlot.Axes;
using VMS.TPS.Common.Model.API;
namespace DvhPlot.Script
{
public class MainViewModel
{
private readonly PlanSetup _plan;
public MainViewModel(PlanSetup plan)
{
_plan = plan ;
Structures = GetPlanStructures();
PlotModel = CreatePlotModel();
}
public IEnumerable<Structure> Structures { get; private set; }
public PlotModel PlotModel { get; private set; }
private IEnumerable<Structure> GetPlanStructures ()
{
return _plan.StructureSet != null
? _plan.StructureSet.Structures
: null ;
}
private PlotModel CreatePlotModel()
{
var plotModel = new PlotModel();
AddAxes(plotModel);
return plotModel;
}
private static void AddAxes(PlotModel plotModel)
{
plotModel.Axes.Add(new LinearAxis
{
Title = "Dose [Gy]",
Position = AxisPosition.Bottom
});
plotModel.Axes.Add(new LinearAxis
{
Title = "Volume [cc]",
Position = AxisPosition.Left
});
}
}
}
The view model’s constructor receives the PlanSetup, which is then stored as a private field. Then, the structures of the plan are extracted and stored in the Structures property. This is the same property that is data-bound to the ItemsControl in XAML. The PlotModel is then created and stored in a property as well. If you remember, this property is data-bound to the PlotView in XAML.
The GetPlanStructures method simply returns the structures in the opened plan, if available (or null if there are none). The CreatePlotModel and AddAxes methods should look familiar. You wrote these methods in the previous script, where they are used to add the x-axis and y-axis to the plot. Notice that there’s no code here that adds the DVH curves, and that’s because they are added only after the user chooses the structures of interest (you’ll see that code in a bit).
If you compile and run this script in Eclipse (or in Visual Studio if you’re using EclipsePlug-InRunner), you’ll see the list of check boxes for each structure and an empty plot. You’re able to select any structure, but the plot is not updated. You still need to write the code to add a DVH curve for each of the structures that the user selects. Open your XAML file (MainView.xaml) and edit the check box to the following:
<CheckBox
Content="{Binding Id}"
Checked="Structure_OnChecked"
Unchecked="Structure_OnUnchecked"
/>
The Checked and Unchecked properties are actually events. These events are triggered when either the check box is checked or unchecked. The values they’re assigned to are the names of methods in the code-behind file (MainView.xaml.cs) that are executed when the associated events occur. Open the MainView.xaml.cs file and change the code to the following:
public partial class MainView : UserControl
{
// Create dummy PlotView to force OxyPlot.Wpf to be loaded
private static readonly PlotView PlotView = new PlotView();
private readonly MainViewModel _vm;
public MainView(MainViewModel viewModel)
{
_vm = viewModel;
InitializeComponent();
DataContext = viewModel;
}
private void Structure_OnChecked(object checkBoxObject, RoutedEventArgs e)
{
_vm.AddDvhCurve(GetStructure(checkBoxObject));
}
private void Structure_OnUnchecked(object checkBoxObject, RoutedEventArgs e)
{
_vm.RemoveDvhCurve(GetStructure(checkBoxObject));
}
private Structure GetStructure(object checkBoxObject)
{
var checkbox = (CheckBox)checkBoxObject;
var structure = (Structure)checkbox.DataContext;
return structure;
}
}
In the constructor you’re now saving the view model object because it’ll be used by other methods. In the event handlers (that is, the methods assigned to Checked and Unchecked), you’re calling the add or remove DVH methods in the view model, passing the in the structure the user checked or unchecked. This structure is extracted from the check box itself, using the check box’s DataContext, which is set to the Structure in the list. Unlike the MainView’s DataContext, you didn’t need to set the check box’s DataContext yourself because the ItemsControl automatically sets the DataContext of its items (in this case, the check box) to the corresponding item from its ItemsSource (in this case, a Structure object).
The AddDvhCurve and RemoveDvhCurve methods don’t exist in the view model yet, so open the MainViewModel class and add the following code:
public void AddDvhCurve(Structure structure)
{
var dvh = CalculateDvh(structure);
PlotModel.Series.Add(CreateDvhSeries(structure.Id, dvh));
UpdatePlot();
}
public void RemoveDvhCurve(Structure structure)
{
var series = FindSeries(structure.Id);
PlotModel.Series.Remove(series);
UpdatePlot();
}
private DVHData CalculateDvh(Structure structure)
{
return _plan.GetDVHCumulativeData(structure,
DoseValuePresentation.Absolute,
VolumePresentation.AbsoluteCm3, 0.01) ;
}
private Series CreateDvhSeries(string structureId, DVHData dvh)
{
var series = new LineSeries {Tag = structureId};
var points = dvh.CurveData.Select(CreateDataPoint);
series.Points.AddRange(points);
return series;
}
private DataPoint CreateDataPoint(DVHPoint p)
{
return new DataPoint(p.DoseValue.Dose, p.Volume);
}
private Series FindSeries(string structureId)
{
return PlotModel.Series.FirstOrDefault(x => (string)x.Tag == structureId);
}
private void UpdatePlot()
{
PlotModel.InvalidatePlot(true);
}
The AddDvhCurve method first calculates the DVH using a method you’ve seen before. Then, the DVH series is created and added to the PlotModel using a method you’ve also seen before. There’s one important difference, though. The ID of the structure is set as the series’s tag. The Tag property lets you associate any object with the series. In this case, you associate the structure’s ID with a specific series. This will make it easy to find the structure later if the related series needs to be removed. Finally, the UpdatePlot method forces the plot to be redrawn by invalidating it. This is the way you cause the PlotView to refresh itself, which you need to do after you make any changes to the PlotModel.
The RemoveDvhCurve method uses the series’s tag to get the correct series, and then removes it from the PlotModel. To find the series, the FindSeries method uses LINQ to return the first item in the Series collection whose tag equals the structure’s ID. The Tag property is cast to a string because its type is actually an object. Again, you need to call UpdatePlot to force the PlotView to redraw itself.
The script is now complete. If you run it, you’ll see a window similar to that shown at the beginning of this post. When you check on a structure, its DVH appears on the plot. When you uncheck it, it disappears. This is a very simple script, and WPF with MVVM may have added more complexity than if you had worked directly with the UI controls themselves. However, as you increase your script’s functionality, separating the view from the data (via view models) will make your code easier to understand and maintain.
Commenti