Introduction
As some of you may know, I am a member of an online community called the WPF Disciples, and we are lucky enough to have some very, very smart people in the group. Occasionally, the group grows by a member, and the other day we had a new member join. When someone joins, they share a bit of work with the group so we can see what others are up to.
This new member showed us this amazing 3D app he had worked on, which inspired me to do a bit more 3D. It also tied in nicely with a very large app that I am in a research phase on, which will be my wife's primary tool for her own Nutritional Therapy business. The app is in a conceptual stage right now, but as I think about things, I am also trying things out, and some of these thoughts will make it into articles right here.
So what does this article do? Simply put, it's a 3D bar chart that allows us to navigate through historical data. It has a somewhat limited use, as it so closely matches my wife's business requirements, but could easily be adapted for someone else's uses, and I do feel it is still a nice example of working with 3D and WPF.
Before I start, I would just like to thank one of my regular partners in crime: Fredrik Bornander, who helped me with some of the finer points of Normals/Camera positions, and generally being cool, as is his way.
What Does It Look Like
This is what it looks like when it starts up:
And here is what it looks like when the user hovers their mouse over a particular item within the chart:
Basically, there is some more information shown in a bar at the top, and ScaleTransform3D
is applied to the ModelUIElement3D
, which you can kind of see with this Yellow bar being a little bigger than its neighbors.
It also makes use of both the TrackballDecorator
and the Interactive3DDecorator
classes found in the 3D Tools for WPF assembly. What this means is that the third chart may be panned/zoomed using the mouse.
- Panning: The user is able to pan with the left mouse button.
- Zooming: The user is able to pan with the right mouse button.
What Does It Do
As I stated, the attached code fulfills a pretty niche requirement that works for my wife's business, but I still feel there is something that could be learned from this article.
But anyway, here was the original brief from my wife gave:
"I want something that I can use to visualize different questions, and answers to those questions over time, where the same five questions will be asked each time I see a client".
So that is what my wife wanted. Sure, you could have visualised this as something like this using standard graphs:
But I feel this chart does not do that much, and I also felt that it just felt a bit static, and did not quite visualize the data in the correct manner. So I set about creating a semi-flexible 3D chart. I say semi-flexible as, it really is fixed to five questions, which was part of my original brief.
So what does it do exactly, Sacha?
Well, it does this:
- Visualizes five bar graphs, one bar for each of my wife's questions to her client.
- Allows users to visualize data about the bar by mousing over it.
- Allows users to view historical data (all in memory at this stage; in the real app, I am building this as persisted to SQL Server).
- Allows users to pan/zoom around the chart.
So if you want to extend this for your own use, you will need to be aware of that limitation.
How Does It Do It
Like I have stated already, although this demo code may not be the most flexible of things, there is still quite a lot of good 3D stuff in there that may be useful to some folk; so rather than go on a tirade about how limited the functionality of the demo app is, and how useless it will be for most of you, let us instead concentrate on the 3D aspects of it that may help some of you out in your own projects.
So what I am going to do is go through how all the different parts of the demo app are constructed, and that will hopefully show you something.
Viewport3D
The ViewPort3D
is required to host any 3D model, and contains some very important children, without which 3D would just not be possible. Let's have a look at what the ViewPort3D
looks like in the demo code:
<Viewport3D x:Name="ViewPort">
<Viewport3D.Camera>
<PerspectiveCamera x:Name="MainCamera"
FieldOfView="90"
Position="-0.7,1.5,1.2"
LookDirection="0.5,-1.5,-2"/>
</Viewport3D.Camera>
<ModelVisual3D>
<ModelVisual3D.Content>
<AmbientLight x:Name="Ambient"
Color="#404040"/>
</ModelVisual3D.Content>
</ModelVisual3D>
<ModelVisual3D>
<ModelVisual3D.Content>
<DirectionalLight x:Name="Directional"
Color="WhiteSmoke" Direction="0.1,-2,-1"/>
</ModelVisual3D.Content>
</ModelVisual3D>
</Viewport3D>
Probably the most important child here is the PerspectiveCamera
, as it's the camera that we point at our 3D constructed model. No camera, no 3D: it's that simple. We also need some light to make sure our 3D scene looks correct. Of course, lighting only works if the Models that are added to the ViewPort3D
are constructed well and have TextureCoordinate
s and Normal
s.
So with this ViewPort3D
in place, we can start to create our 3D model, but by bit (that works best for me).
Creating a BasePlate That Can Host 2D Content
So the first thing I want to show you is how we can construct a base plate that can be used to host 3D content or even 2D controls such as Grid
, StackPanel
, or even interactive controls such as Button
.
Here is what we are trying to build:
You can see that this is indeed a 3D model that looks like it is made up of some standard 3D meshes, and also looks like it's hosting some 2D control.
How does it do that? Well, the trick lies in creating a 3D cube which is between -0.5 / 0.5 on all axis and is centered around 0.0, and is then scaled to create whatever shape (as long as it is rectangular) we want.
Let's look at how the demo app creates this 3D part:
Creating the Base
private void CreateBasePlateAndBarContainer()
{
graphBaseModel = CubeBuilder.Build(
Brushes.WhiteSmoke,
Brushes.WhiteSmoke,
Brushes.WhiteSmoke,
Brushes.WhiteSmoke,
graphSurface,
Brushes.WhiteSmoke);
Transform3DGroup trans = new Transform3DGroup();
trans.Children.Add(new ScaleTransform3D(1.0,
CubeBuilder.BASE_HEIGHT, 1.0));
graphBaseModel.Transform = trans;
ViewPort.Children.Add(graphBaseModel);
ViewPort.Children.Add(barsContainer);
}
It can be seen that this makes use of a Factory method on a CubeBuilder
class. So let us examine that CubeBuilder
class, shall we?
Here it is in its entirety. The idea is simple enough: build a cube side by side where each side is a Visual3D
which internally uses the following MeshGeometry3D
as a Mesh. Then, each side is rotated in 3D space to be positioned as the correct face of the cube.
<MeshGeometry3D x:Key="CubeMesh"
TriangleIndices = "0,1,2 2,3,0
4,7,6 6,5,4
8,11,10 10,9,8
12,13,14 14,15,12
16,17,18 18,19,16
20,23,22 22,21,20"
Positions = "-0.5,-0.5,0.5 -0.5,-0.5,-0.5 0.5,-0.5,-0.5 0.5,-0.5,0.5
-0.5,0.5,0.5 -0.5,0.5,-0.5 0.5,0.5,-0.5 0.5,0.5,0.5
-0.5,-0.5,0.5 -0.5,0.5,0.5 0.5,0.5,0.5 0.5,-0.5,0.5
-0.5,-0.5,-0.5 -0.5,0.5,-0.5 0.5,0.5,-0.5 0.5,-0.5,-0.5
-0.5,-0.5,0.5 -0.5,0.5,0.5 -0.5,0.5,-0.5 -0.5,-0.5,-0.5
0.5,-0.5,0.5 0.5,0.5,0.5 0.5,0.5,-0.5 0.5,-0.5,-0.5" />
And here is the CubeBuilder
class. It can be seen that each side is a single Visual3D
which may or may not be a 2D control.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Media3D;
namespace CubeDemo
{
class CubeBuilder
{
public const Double BASE_HEIGHT = 0.1;
public enum Side
{
Front,
Back,
Left,
Right,
Top,
Bottom
}
private static readonly MeshGeometry3D quadMesh;
private static readonly Material visualHostMaterial;
static CubeBuilder()
{
quadMesh = new MeshGeometry3D();
quadMesh.Positions.Add(new Point3D(-0.5, 0.5, 0));
quadMesh.Positions.Add(new Point3D(-0.5, -0.5, 0));
quadMesh.Positions.Add(new Point3D(0.5, -0.5, 0));
quadMesh.Positions.Add(new Point3D(0.5, 0.5, 0));
quadMesh.TextureCoordinates.Add(new Point(0, 0));
quadMesh.TextureCoordinates.Add(new Point(0, 1));
quadMesh.TextureCoordinates.Add(new Point(1, 1));
quadMesh.TextureCoordinates.Add(new Point(1, 0));
quadMesh.Normals.Add(new Vector3D(0, 0, 1));
quadMesh.Normals.Add(new Vector3D(0, 0, 1));
quadMesh.Normals.Add(new Vector3D(0, 0, 1));
quadMesh.Normals.Add(new Vector3D(0, 0, 1));
quadMesh.TriangleIndices.Add(0);
quadMesh.TriangleIndices.Add(1);
quadMesh.TriangleIndices.Add(2);
quadMesh.TriangleIndices.Add(0);
quadMesh.TriangleIndices.Add(2);
quadMesh.TriangleIndices.Add(3);
visualHostMaterial = new DiffuseMaterial(
new SolidColorBrush(Colors.White));
visualHostMaterial.SetValue(
Viewport2DVisual3D.IsVisualHostMaterialProperty, true);
}
private static Visual3D CreateSide(object material, Rotation3D rotation)
{
Transform3DGroup transform = new Transform3DGroup();
Transform3D translation = new TranslateTransform3D(0, 0, 0.5);
transform.Children.Add(translation);
transform.Children.Add(new RotateTransform3D(rotation));
if (material is Visual)
{
return new Viewport2DVisual3D
{
Geometry = quadMesh,
Visual = (Visual)material,
Material = visualHostMaterial,
Transform = transform
};
}
else
{
GeometryModel3D model = new GeometryModel3D(quadMesh,
new DiffuseMaterial((Brush)material));
return new ModelVisual3D { Content=model, Transform=transform};
}
}
public static ModelVisual3D Build(object front, object back,
object left, object right, object top, object bottom)
{
IDictionary<Side, Visual3D> sides =
new Dictionary<Side, Visual3D>();
sides[Side.Front] = CreateSide(front, new
AxisAngleRotation3D(new Vector3D(1, 0, 0), 0));
sides[Side.Back] = CreateSide(back, new
AxisAngleRotation3D(new Vector3D(1, 0, 0), 180));
sides[Side.Left] = CreateSide(left, new
AxisAngleRotation3D(new Vector3D(0, 1, 0), 90));
sides[Side.Right] = CreateSide(right, new
AxisAngleRotation3D(new Vector3D(0, 1, 0), -90));
sides[Side.Top] = CreateSide(top, new
AxisAngleRotation3D(new Vector3D(1, 0, 0), -90));
sides[Side.Bottom] = CreateSide(bottom, new
AxisAngleRotation3D(new Vector3D(1, 0, 0), 90));
ModelVisual3D cube = new ModelVisual3D();
foreach (Visual3D side in sides.Values)
{
cube.Children.Add(side);
}
return cube;
}
}
}
Creating the 2D Content
As already shown in the diagram above, all we need to do now is create a 2D control and pass it to the CubeBuilder
and it takes care of the rest. Just to revisit this code, have another look, see how CubeBuilder.Build()
takes a "graphSurface
" as one of the parameters.
private void CreateBasePlateAndBarContainer()
{
graphBaseModel = CubeBuilder.Build(
Brushes.WhiteSmoke,
Brushes.WhiteSmoke,
Brushes.WhiteSmoke,
Brushes.WhiteSmoke,
graphSurface,
Brushes.WhiteSmoke);
Transform3DGroup trans = new Transform3DGroup();
trans.Children.Add(new ScaleTransform3D(1.0,
CubeBuilder.BASE_HEIGHT, 1.0));
graphBaseModel.Transform = trans;
ViewPort.Children.Add(graphBaseModel);
ViewPort.Children.Add(barsContainer);
}
If we look into this a bit further, we can see that "graphSurface
" is an instance of a 2D control of type GraphSurface
. Which is a standard WPF control whose XAML looks like this:
<UserControl x:Class="CubeDemo.GraphSurface"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Height="Auto" Width="Auto">
<Border BorderBrush="Black"
BorderThickness="3" Background="CornflowerBlue">
<Grid x:Name="gridItems" Margin="3"
Width="390" Height="360">
<Grid.RowDefinitions>
<RowDefinition Height="60"/>
<RowDefinition Height="60"/>
<RowDefinition Height="60"/>
<RowDefinition Height="60"/>
<RowDefinition Height="60"/>
<RowDefinition Height="60"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="60"/>
<ColumnDefinition Width="60"/>
<ColumnDefinition Width="60"/>
<ColumnDefinition Width="60"/>
<ColumnDefinition Width="60"/>
</Grid.ColumnDefinitions>
<Canvas Grid.Row="5" Grid.Column="1"
Width="60" Height="60" Margin="0">
<Ellipse Width="50" Height="50"
Fill="Black" Margin="5"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Ellipse Width="40" Height="40"
Fill="White" Margin="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Label Width="40" Height="40"
Content="1" Margin="10"
FontSize="30" FontWeight="Bold"
FontFamily="Arial"
HorizontalAlignment="Center"
HorizontalContentAlignment="Center"
VerticalAlignment="Center"
VerticalContentAlignment="Center"/>
</Canvas>
<Canvas Grid.Row="5" Grid.Column="2"
Width="60" Height="60" Margin="0">
<Ellipse Width="50" Height="50"
Fill="Black" Margin="5"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Ellipse Width="40" Height="40"
Fill="White" Margin="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Label Width="40" Height="40"
Content="2" Margin="10"
FontSize="30" FontWeight="Bold"
FontFamily="Arial"
HorizontalAlignment="Center"
HorizontalContentAlignment="Center"
VerticalAlignment="Center"
VerticalContentAlignment="Center"/>
</Canvas>
<Canvas Grid.Row="5" Grid.Column="3"
Width="60" Height="60"
Margin="0">
<Ellipse Width="50" Height="50"
Fill="Black" Margin="5"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Ellipse Width="40" Height="40"
Fill="White" Margin="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Label Width="40" Height="40"
Content="3" Margin="10"
FontSize="30" FontWeight="Bold"
FontFamily="Arial"
HorizontalAlignment="Center"
HorizontalContentAlignment="Center"
VerticalAlignment="Center"
VerticalContentAlignment="Center"/>
</Canvas>
<Canvas Grid.Row="5" Grid.Column="4"
Width="60" Height="60" Margin="0">
<Ellipse Width="50" Height="50"
Fill="Black" Margin="5"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Ellipse Width="40" Height="40"
Fill="White" Margin="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Label Width="40" Height="40"
Content="4" Margin="10"
FontSize="30"
FontWeight="Bold" FontFamily="Arial"
HorizontalAlignment="Center"
HorizontalContentAlignment="Center"
VerticalAlignment="Center"
VerticalContentAlignment="Center"/>
</Canvas>
<Canvas Grid.Row="5" Grid.Column="5"
Width="60" Height="60" Margin="0">
<Ellipse Width="50" Height="50"
Fill="Black" Margin="5"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Ellipse Width="40" Height="40"
Fill="White" Margin="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Label Width="40" Height="40"
Content="5" Margin="10"
FontSize="30" FontWeight="Bold"
FontFamily="Arial"
HorizontalAlignment="Center"
HorizontalContentAlignment="Center"
VerticalAlignment="Center"
VerticalContentAlignment="Center"/>
</Canvas>
</Grid>
</Border>
</UserControl>
There is some additional code-behind to draw the square outlines, but that is not that important. The important thing here is to see how this 2D control gets hosted in 3D by the CubeBuilder.Build()
method.
Creating Interactive Bars
So right now, we have this:
Now we need to create some actual bars. How do we do that?
Well, that is done once we have the correct page of data, and once we have that, it really is just a matter of creating some more 3D Models. Only this time, we want the Models to be ModelUIElement3D
, which are actual elements that can work in 3D in WPF. They allow events such as MouseDown
/MouseLeave
etc. The only thing with them is that they must be hosted as children in a ContainerUIElement3D
.
Anyway, assuming we have a page worth of reading to display as bars, how do we do that? This is the code that does the job:
for (int readingDates = 0; readingDates < readingSetList.Count; readingDates++)
{
for (int r = 0; r < readingSetList[readingDates].Readings.Count; r++)
{
Reading reading = readingSetList[readingDates].Readings[r];
ModelUIElement3D bar1 = BarBuilder.CreateBarModelUIElement(
reading.Value,
readingDates,
reading.QuestionNumberIndex,
(MeshGeometry3D)this.Resources["CubeMesh"],
brushes[r]);
bar1.SetValue(BarBuilderBehaviors.AssociatedReadingProperty, reading);
bar1.SetValue(BarBuilderBehaviors.AssociatedReadingDateProperty,
readingSetList[readingDates].ReadingsDate);
bar1.MouseEnter += Bar_MouseEnter;
bar1.MouseLeave += Bar_MouseLeave;
barsContainer.Children.Add(bar1);
}
}
Again, notice the Factory BarBuilder.CreateBarModelUIElement()
; this is used to create a ModelUIElement3D
bar. So, let's have a look at that code in its entirety; it's very simple.
public static class BarBuilder
{
#region Private Properties
private static Double[] QuestionNumberIndexPositions =
{
-0.18, -0.04, 0.11, 0.26, 0.40
};
private static Double[] DateOfReadingPositions =
{
0.24, 0.08, -0.08, -0.24, -0.40
};
#endregion
#region Public Methods
public static T TryFindChild<T>(Transform3DGroup parent)
where T : DependencyObject
{
foreach (DependencyObject child in parent.Children)
{
if (child is T)
{
return child as T;
}
}
return null;
}
public static ModelUIElement3D CreateBarModelUIElement(
Double readingValue,
Int32 dateOfReading,
Int32 questionNumberIndex,
MeshGeometry3D mesh, Brush brush)
{
ModelUIElement3D modelUIElement3D = new ModelUIElement3D()
{
Model = new GeometryModel3D(mesh, new DiffuseMaterial(brush))
};
Transform3DGroup transform = new Transform3DGroup();
transform.Children.Add(new ScaleTransform3D(0.1, readingValue, 0.1));
transform.Children.Add(
new TranslateTransform3D(
QuestionNumberIndexPositions[questionNumberIndex-1],
(CubeBuilder.BASE_HEIGHT/2) + (readingValue/2),
DateOfReadingPositions[dateOfReading]));
modelUIElement3D.Transform = transform;
return modelUIElement3D;
}
#endregion
}
Now, since the bars are of type ModelUIElement3D
, we can listen to their events just like regular 2D elements. Here is an example of the mouse handlers for a single bar ModelUIElement3D
:
private void Bar_MouseEnter(object sender, MouseEventArgs e)
{
ModelUIElement3D modelUIElement3D = (ModelUIElement3D)sender;
GeometryModel3D model = (GeometryModel3D)modelUIElement3D.Model;
ScaleTransform3D scale = BarBuilder.TryFindChild<ScaleTransform3D>(
modelUIElement3D.Transform as Transform3DGroup);
scale.ScaleX = 0.13;
scale.ScaleZ = 0.13;
Reading reading = (Reading)modelUIElement3D.GetValue(
BarBuilderBehaviors.AssociatedReadingProperty);
DateTime datetime = (DateTime)modelUIElement3D.GetValue(
BarBuilderBehaviors.AssociatedReadingDateProperty);
lblQuestion.Content = String.Format("Question{0} : {1}",
reading.QuestionNumberIndex, reading.QuestionText);
lblDate.Content =
String.Format("Date : {0}", datetime.ToShortDateString());
rateValue.Value = (Decimal)(reading.Value * 10);
lblValue.Content = String.Format("Percentage Value : {0} % ",
((Int32)(reading.Value * 100)).ToString());
bordCurrentItem.Visibility = Visibility.Visible;
}
private void Bar_MouseLeave(object sender, MouseEventArgs e)
{
bordCurrentItem.Visibility = Visibility.Collapsed;
ModelUIElement3D modelUIElement3D = (ModelUIElement3D)sender;
GeometryModel3D model = (GeometryModel3D)modelUIElement3D.Model;
ScaleTransform3D scale = BarBuilder.TryFindChild<ScaleTransform3D>(
modelUIElement3D.Transform as Transform3DGroup);
scale.ScaleX = 0.1;
scale.ScaleZ = 0.1;
}
Basically, on MouseEnter
, the current ModelUIElement3D
is scaled up a bit to show it is selected, and then some attached property data is retrieved and used to show the user the data about the current bar.
On MouseLeave
, the data about the current bar is hidden.
Note: The star rating control is from one of my previous articles: WPFStarRating.aspx.
Panning/Zooming
As I previously stated, panning and zooming are accomplished using the TrackballDecorator
and the Interactive3DDecorator
classes found in the 3D Tools for WPF assembly.
But how do we use these classes? Well, it is very easy actually; all we do is adorn our ViewPort3D
as follows, and that's it, job done.
<inter3D:TrackballDecorator x:Name="inter3d" >
<inter3D:Interactive3DDecorator>
<Viewport3D x:Name="ViewPort">
....
....
....
....
</Viewport3D>
</inter3D:Interactive3DDecorator>
</inter3D:TrackballDecorator>
Historical Data
As I stated earlier on in the article, I do allow the user to page through historical data. Now, ordinarily, this data would be stored in SQL Server (which it is for the actual app), but for this demo app, I wanted readers to be able to just run the attached demo app without hindrance and without setting up a new DB etc.
So although I do allow paging through historical data, this is all done by having the entire dataset in memory and then using some LINQ to grab the page of data that is required. I decided a page size would be 5.
The graph data is created like this, where there is a MockData
class that creates some dummy data for the chart:
public static void SetupMockData(Int32 numberOfHistoricalDates)
{
DataReadings.Instance.ReadingSets = new List<ReadingSet>();
for (int dates = 0; dates < numberOfHistoricalDates; dates++)
{
List<Reading> readings = new List<Reading>();
for (int reading = 0; reading < 5; reading++)
{
readings.Add(new Reading(rand.NextDouble(), reading + 1));
}
DataReadings.Instance.ReadingSets.Add(
new ReadingSet(DateTime.Now.AddDays(dates),readings));
readings = new List<Reading>();
}
}
Which is set as the chart's data as follows:
MockData.SetupMockData(16);
Where the data structures that represent data for the chart looks like this:
public class DataReadings
{
#region Data
public const Int32 MAX_HISTORICAL_BARS_ALLOWS = 5;
private static DataReadings instance;
private List<ReadingSet> readingSets { get; set; }
private String[] questions =
{
"Are you enjoying the supplement plan, between 0-100% ?",
"Any you finding that you are still hungry, between 0-100% ?",
"Do you feel weak or unwell, between 0-100% ?",
"How are your concentration levels, between 0-100% ?",
"Do you feel less tired since you last visit, between 0-100% ?"
};
#endregion
#region Ctor
private DataReadings()
{
CurrentPageNumber = 1;
}
#endregion
#region Public Properties
public static DataReadings Instance
{
get
{
if (instance == null)
{
instance = new DataReadings();
}
return instance;
}
}
public Int32 CurrentPageNumber { get; set; }
public List<ReadingSet> ReadingSets
{
get { return readingSets; }
set
{
if (value == null)
throw new NullReferenceException("Readings can not be null");
readingSets = value;
}
}
public string this[int questionNumber]
{
get { return questions[questionNumber]; }
}
#endregion
}
public class ReadingSet
{
#region Ctor
public ReadingSet(DateTime readingsDate, List<Reading> readings)
{
if (readings == null)
throw new NullReferenceException("Readings can not be null");
if (readings.Count != 5)
throw new InvalidOperationException(
"Readings must contain exactly 5 elements");
Readings = readings;
ReadingsDate = readingsDate;
}
#endregion
#region Public Properties
public List<Reading> Readings { get; private set; }
public DateTime ReadingsDate { get; private set; }
#endregion
}
public class Reading
{
#region Ctor
public Reading(Double value, Int32 questionNumberIndex)
{
if (value > MAX)
Value = MAX;
else
Value = value;
if (value < MIN)
Value = MIN;
else
Value = value;
QuestionNumberIndex = questionNumberIndex;
}
#endregion
#region Public Properties
public const Double MAX = 1.0;
public const Double MIN = 0.0;
public Double Value { get; private set; }
public Int32 QuestionNumberIndex { get; private set; }
public String QuestionText
{
get { return DataReadings.Instance[QuestionNumberIndex-1]; }
}
#endregion
}
And the bars for the current chart are created by paging through the single set of data, which is done as follows:
private void BuildBarsForReadings(Int32 pageNumber)
{
float pages = (float)DataReadings.Instance.ReadingSets.Count /
DataReadings.MAX_HISTORICAL_BARS_ALLOWS;
Int32 roundedUpPages = (Int32)pages;
if (pages % 1 > 0)
++roundedUpPages;
lblPaging.Content = String.Format("Page {0} of {1}",
pageNumber, roundedUpPages);
barsContainer.Children.Clear();
IEnumerable<ReadingSet> readingSets;
if (DataReadings.Instance.ReadingSets.Count >=
pageNumber * DataReadings.MAX_HISTORICAL_BARS_ALLOWS)
{
readingSets = DataReadings.Instance.ReadingSets.Skip(
(pageNumber - 1) * DataReadings.MAX_HISTORICAL_BARS_ALLOWS).Take(
DataReadings.MAX_HISTORICAL_BARS_ALLOWS);
}
else
{
readingSets = DataReadings.Instance.ReadingSets.Skip((pageNumber - 1) *
DataReadings.MAX_HISTORICAL_BARS_ALLOWS);
}
List<ReadingSet> readingSetList = readingSets.ToList();
graphSurface.CurrentReadingSets = readingSetList;
....
....
....
....
}
private void Prev_Click(object sender, RoutedEventArgs e)
{
if (DataReadings.Instance.CurrentPageNumber-1 > 0)
{
DataReadings.Instance.CurrentPageNumber -= 1;
BuildBarsForReadings(DataReadings.Instance.CurrentPageNumber);
}
}
private void Next_Click(object sender, RoutedEventArgs e)
{
if (DataReadings.Instance.CurrentPageNumber *
DataReadings.MAX_HISTORICAL_BARS_ALLOWS <
DataReadings.Instance.ReadingSets.Count())
{
DataReadings.Instance.CurrentPageNumber += 1;
BuildBarsForReadings(DataReadings.Instance.CurrentPageNumber);
}
}
Known Limitations
Only works for five bars; as this was my original requirement, I am not too fussed about this.
That's it, Hope You Liked It
Anyway, there you go, hope you liked it. I know this is a very small article, but I am hoping it may be useful to someone.
Thanks
As always, votes / comments are welcome.