It is possible to do most of this with a tooltip, but I found it easier (and more fun) to do it with a popup.
I'm making use of the AdventureWorks database again with this blog entry. We'll be writing a screen that performs scrap rate analysis on the WorkOrder table. Start a new WPF, C# project and call it ScrapRatesByMonth. I'm using Visual Studio 2019 and Framework 4.7.2
There is a canvas which will host the graph, a fixed table of summary data, and a mobile table that will act as the tooltip. We use the Canvas Loaded event to grab a reference to the Canvas so we can write on it. Note how we control the position of the second data grid by setting the alignments and binding the margin.
<Window x:Class="ScrapRatesByMonth.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
DataContext="{Binding RelativeSource={RelativeSource Self}}"
Title="Scrap
Rates by Month" Height="450" Width="800">
<Grid>
<Canvas Loaded="Canvas_Loaded" Background="AliceBlue"/>
<DataGrid ItemsSource="{Binding ScrapRatesByReason}" VerticalAlignment="Top" HorizontalAlignment="Right" Margin="0,10,10,0" AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="None">
<DataGrid.Columns>
<DataGridTextColumn Binding="{Binding Reason}"/>
<DataGridTextColumn Binding="{Binding Total, StringFormat={}{0}%}"/>
</DataGrid.Columns>
</DataGrid>
<DataGrid ItemsSource="{Binding ScrapReasonsForMonth}" Visibility="{Binding ToolTipVisibility}" HorizontalAlignment="Left" VerticalAlignment="Top" Margin="{Binding ToolTipMargin}"
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="None">
<DataGrid.Columns>
<DataGridTextColumn Binding="{Binding Reason}"/>
<DataGridTextColumn Binding="{Binding Total, StringFormat={}{0}%}"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
</Window>
While the XAML is simple, the code-behind is quite complex.
Start by replacing the auto-generated code-behind with code that includes all the usings we will need, implements INotifyPropertyChanged and also captures a reference to the canvas.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Data.SqlClient;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
namespace ScrapRatesByMonth
{
public partial class MainWindow : Window, INotifyPropertyChanged
{
private Canvas canvas;
public MainWindow()
{
InitializeComponent();
}
private void
Canvas_Loaded(object sender,
RoutedEventArgs e)
{
canvas = (Canvas)sender;
}
public event PropertyChangedEventHandler PropertyChanged;
public bool
SetProperty<T>(ref T storage, T value,
[CallerMemberName] string
propertyName = null)
{
if (Object.Equals(storage, value)) return false;
storage = value;
OnPropertyChanged(propertyName);
return true;
}
private void
OnPropertyChanged(String PropertyName = "")
{
if (PropertyChanged != null)
{
PropertyChanged(this, new PropertyChangedEventArgs(PropertyName));
}
}
}
}
private DataTable ScrapRates;
public MainWindow()
{
ScrapRates = LoadScrapRates();
InitializeComponent();
}
private DataTable LoadScrapRates()
{
string sConn = "Server=localhost;Database=AdventureWorks2017;Trusted_Connection=True";
string SQL = "SELECT SUM(ScrappedQty) AS
ScrappedQty, SUM(OrderQty) AS OrderQty, CAST(DatePart(yyyy, StartDate) AS
VARCHAR(4)) + '/' + RIGHT('00' + CAST(DatePart(mm, StartDate) AS VARCHAR(20)),
2) AS StartYearMonth, SR.Name AS ScrapReason FROM Production.WorkOrder WO INNER
JOIN Production.ScrapReason SR ON WO.ScrapReasonID=SR.ScrapReasonID GROUP BY
CAST(DatePart(yyyy, StartDate) AS VARCHAR(4)) + '/' + RIGHT('00' +
CAST(DatePart(mm, StartDate) AS VARCHAR(20)), 2), SR.Name HAVING SUM(OrderQty)
> 0 ORDER BY 3, 4";
SqlConnection oConn = new SqlConnection(sConn);
SqlDataAdapter da = new SqlDataAdapter(SQL, sConn);
DataTable dt = new DataTable();
da.Fill(dt);
oConn.Close();
return dt;
}
public class cScrapRateByReason
{
public string Reason {
get; set; }
public decimal Total { get; set; }
}
private List<cScrapRateByReason> _ScrapRatesByReason;
public List<cScrapRateByReason> ScrapRatesByReason
{
get { return
_ScrapRatesByReason; }
set { SetProperty(ref _ScrapRatesByReason, value); }
}
private List<cScrapRateByReason> LoadScrapTotalByReason()
{
decimal TotalOrderQty = ScrapRates.Select().Sum(r =>
Convert.ToDecimal(r["OrderQty"]));
decimal TotalScrapQty = ScrapRates.Select().Sum(r =>
Convert.ToDecimal(r["ScrappedQty"]));
List<cScrapRateByReason> stbr = new List<cScrapRateByReason>();
stbr.Add(new cScrapRateByReason { Reason = "Total
Scrap Rates", Total = Math.Round(100 *
TotalScrapQty / TotalOrderQty,2 ) });
foreach (string Reason in ScrapRates.Select().Select((sr)
=> sr["ScrapReason"]).Distinct())
stbr.Add(new cScrapRateByReason { Reason = Reason, Total = Math.Round(Convert.ToDecimal(ScrapRates.Compute("SUM(ScrappedQty)", "ScrapReason='" + Reason + "'")) / TotalOrderQty * 100,
2) });
return stbr.OrderByDescending(r => r.Total).ToList();
}
private void
Canvas_Loaded(object sender, RoutedEventArgs e)
{
canvas = (Canvas)sender;
ScrapRatesByReason =
LoadScrapTotalByReason();
}
Now we need to create a list of data points that can drive the graph. Note this only done once whereas the graph can be rendered multiple times. We create a class, a list of that class, code to populate the list, and a call to that code.
public class cScrapRateByMonth
{
public string
YearMonth { get; set; }
public decimal
ScrapRate { get; set; }
public double XOffset;
}
private List<cScrapRateByMonth> _ScrapRatesByMonth;
public List<cScrapRateByMonth> ScrapRateByMonth
{
get { return
_ScrapRatesByMonth; }
set { SetProperty(ref _ScrapRatesByMonth, value); }
}
private List<cScrapRateByMonth> LoadScrapRateByMonth()
{
decimal MonthOrderQty;
List<cScrapRateByMonth> srbm = new List<cScrapRateByMonth>();
foreach (string
YearMonth in ScrapRates.Select().Select((sr)
=> sr["StartYearMonth"]).Distinct())
{
MonthOrderQty = ScrapRates.Select("StartYearMonth='" + YearMonth + "'").Sum(r =>
Convert.ToDecimal(r["OrderQty"]));
srbm.Add(new cScrapRateByMonth { YearMonth = YearMonth, ScrapRate =
Math.Round(Convert.ToDecimal(ScrapRates.Compute("SUM(ScrappedQty)", "StartYearMonth='" + YearMonth + "'")) / MonthOrderQty * 100, 2) });
}
return srbm;
}
private void Canvas_Loaded(object sender, RoutedEventArgs e)
{
canvas = (Canvas)sender;
ScrapRatesByReason = LoadScrapTotalByReason();
ScrapRateByMonth = LoadScrapRateByMonth();
}
private void
RenderChart()
{
double Margin = 10;
double LegendHeight = 60;
double LegendWidth = 40;
double TickLength = 5;
double OriginX = Margin + LegendWidth;
double OriginY = canvas.ActualHeight -
Margin - LegendHeight;
double XLeft = canvas.ActualWidth - (2 *
Margin) - LegendWidth;
double YTop = Margin;
double XWidth = XLeft - OriginX;
double YHeight = OriginY - YTop;
canvas.Children.Clear();
// Axes
canvas.Children.Add(new Line { X1 = OriginX, X2 = XLeft, Y1 = OriginY, Y2 = OriginY,
StrokeThickness = 1, Stroke = new SolidColorBrush(Colors.Black) });
// X axis
canvas.Children.Add(new Line { X1 = OriginX, X2 = OriginX, Y1 = OriginY, Y2 = YTop,
StrokeThickness = 1, Stroke = new SolidColorBrush(Colors.Black) }); // Y axis
// Ticks and tick legends
Double MonthWidth = XWidth / (ScrapRateByMonth.Count() - 1);
double X;
for (int i = 0; i < ScrapRateByMonth.Count(); i++)
{
X
= OriginX + i * MonthWidth;
canvas.Children.Add(new Line { X1 = X, X2 = X, Y1 = OriginY, Y2 = OriginY + TickLength,
StrokeThickness = 1, Stroke = new SolidColorBrush(Colors.DarkGray) });
TextBlock tb = new
TextBlock { Text = ScrapRateByMonth[i].YearMonth, RenderTransformOrigin = new Point(0, 0.5), RenderTransform = new RotateTransform(90)};
canvas.Children.Add(tb);
Canvas.SetLeft(tb, X);
Canvas.SetTop(tb, OriginY + TickLength);
ScrapRateByMonth[i].XOffset = X;
}
// Determine the highest value on the Y axis and the value between
ticks
decimal MaxScrapRate = ScrapRateByMonth.Max((r) => r.ScrapRate);
List<int> PossibleYMax = new List<int> {
100, 50, 20, 10 };
int YMax = PossibleYMax.Last((m) => m > MaxScrapRate);
int YDelta = (YMax > 50) ? 10 : 1;
double PercentHeight = YHeight / YMax;
for (int i = 0; i
<= YMax; i += YDelta)
{
canvas.Children.Add(new Line { X1 = OriginX - TickLength,
X2 = OriginX, Y1 = OriginY - i * PercentHeight, Y2 = OriginY - i *
PercentHeight, StrokeThickness = 1, Stroke = new SolidColorBrush(Colors.DarkGray) });
TextBlock tb = new TextBlock { Text = i.ToString() + "%" };
canvas.Children.Add(tb);
Canvas.SetLeft(tb, Margin);
Canvas.SetTop(tb, OriginY - i *
PercentHeight - 5);
}
// Draw the
graph
for (int i = 0; i
< ScrapRateByMonth.Count() - 1; i++)
canvas.Children.Add(new Line { X1 = OriginX + i *
MonthWidth, X2 = OriginX + (i + 1) * MonthWidth, Y1 = OriginY - (double)ScrapRateByMonth[i].ScrapRate *
PercentHeight, Y2 = OriginY - (double)ScrapRateByMonth[i + 1].ScrapRate * PercentHeight,
StrokeThickness = 1, Stroke = new SolidColorBrush(Colors.Blue) });
}
private void Canvas_Loaded(object sender, RoutedEventArgs e)
{
canvas = (Canvas)sender;
canvas.SizeChanged += Canvas_SizeChanged;
ScrapRatesByReason = LoadScrapTotalByReason();
ScrapRateByMonth = LoadScrapRateByMonth();
RenderChart();
}
private void
Canvas_SizeChanged(object sender,
SizeChangedEventArgs e)
{
RenderChart();
}
The application now looks like this. Try resizing it and seeing how the graph resizes too.
So far we've seen some interesting techniques for manipulating data in data tables and in lists of objects, but the purpose of this blog entry is to show a smart 'tooltip'. We need to add three more event handlers to the canvas. Note, if you don't set the canvas' background, these event handlers will be ignored. Weird, huh?
private void
Canvas_Loaded(object sender, RoutedEventArgs e)
{
canvas = (Canvas)sender;
canvas.SizeChanged += Canvas_SizeChanged;
canvas.MouseLeftButtonDown +=
Canvas_MouseLeftButtonDown;
canvas.MouseLeftButtonUp +=
Canvas_MouseLeftButtonUp;
canvas.MouseMove += Canvas_MouseMove;
ScrapRatesByReason =
LoadScrapTotalByReason();
ScrapRateByMonth = LoadScrapRateByMonth();
RenderChart();
}
private void
Canvas_MouseMove(object sender,
MouseEventArgs e)
{
}
private void
Canvas_MouseLeftButtonUp(object sender,
MouseButtonEventArgs e)
{
}
private void
Canvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
}
private Visibility _ToolTipVisibility = Visibility.Hidden;
public Visibility ToolTipVisibility
{
get { return
_ToolTipVisibility; }
set { SetProperty(ref _ToolTipVisibility, value); }
}
private Thickness _ToolTipMargin = new Thickness();
public Thickness ToolTipMargin
{
get { return
_ToolTipMargin; }
set { SetProperty(ref _ToolTipMargin, value); }
}
private void
Canvas_MouseMove(object sender,
MouseEventArgs e)
{
if (ToolTipVisibility == Visibility.Visible)
{
SetTooltipLocation();
}
}
private void
Canvas_MouseLeftButtonUp(object sender,
MouseButtonEventArgs e)
{
ToolTipVisibility = Visibility.Collapsed;
Mouse.Capture(null);
}
private void
Canvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
SetTooltipLocation();
ToolTipVisibility = Visibility.Visible;
Mouse.Capture(canvas);
}
private void
SetTooltipLocation()
{
Double X = Mouse.GetPosition(canvas).X +
10;
Double Y = Mouse.GetPosition(canvas).Y +
10;
if (X < 10) X = 10;
if (Y < 10) Y = 10;
if (X + 300 > canvas.ActualWidth) X = canvas.ActualWidth - 300;
if (Y + 200 > canvas.ActualHeight) Y = canvas.ActualHeight - 200;
ToolTipMargin = new Thickness(X, Y, 0, 0);
}
public class cScrapReasonForMonth
{
public string Reason {
get; set; }
public decimal Total { get; set; }
}
private List<cScrapReasonForMonth> _ScrapReasonsForMonth = new List<cScrapReasonForMonth>();
public List<cScrapReasonForMonth> ScrapReasonsForMonth
{
get { return
_ScrapReasonsForMonth; }
set { SetProperty(ref _ScrapReasonsForMonth, value); }
}
We start by determining which Year/Month the mouse is closest too. If it hasn't changed then we don't need to refresh ScrapReasonsForMonth (bit of a scope kludge here), otherwise we repopulate it.
private List<cScrapReasonForMonth> LoadScrapReasonsForMonth()
{
List<cScrapReasonForMonth> srfm = new List<cScrapReasonForMonth>();
double NearestX = double.MaxValue;
string NearestYearMonth = "";
Point p = Mouse.GetPosition(canvas);
foreach (cScrapRateByMonth srbm in ScrapRateByMonth)
{
double CurrentDelta = Math.Abs(p.X - srbm.XOffset);
if (CurrentDelta < NearestX)
{
NearestX = CurrentDelta;
NearestYearMonth = srbm.YearMonth;
}
}
if (NearestYearMonth == LastNearestYearMonth) return ScrapReasonsForMonth;
if (NearestYearMonth != "")
{
decimal MonthOrderQty = ScrapRates.Select("StartYearMonth='" + NearestYearMonth + "'").Sum(r => Convert.ToDecimal(r["OrderQty"]));
LastNearestYearMonth =
NearestYearMonth;
srfm.Add(new cScrapReasonForMonth { Reason = NearestYearMonth + " Total scrap rate", Total =
ScrapRateByMonth.First(r => r.YearMonth == NearestYearMonth).ScrapRate });
foreach (DataRow dr in
ScrapRates.Select("StartYearMonth='" + NearestYearMonth + "'").OrderByDescending(r => Convert.ToDecimal(r["ScrappedQty"])))
{
if (srfm.Count() < 6)
srfm.Add(new cScrapReasonForMonth { Reason = dr["ScrapReason"].ToString(),
Total = Math.Round(Convert.ToDecimal(dr["ScrappedQty"]) / MonthOrderQty * 100, 2) });
}
}
return srfm;
}
private void
Canvas_MouseMove(object sender, MouseEventArgs e)
{
if (ToolTipVisibility == Visibility.Visible)
{
SetTooltipLocation();
ScrapReasonsForMonth =
LoadScrapReasonsForMonth();
}
}
private void
Canvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
ScrapReasonsForMonth =
LoadScrapReasonsForMonth();
SetTooltipLocation();
ToolTipVisibility = Visibility.Visible;
Mouse.Capture(canvas);
}
No comments:
Post a Comment