Monday, April 22, 2019

Context sensitive tooltip that follows the cursor

I have a graph that I created the hard way, using line controls, and I want the user to be able to see a tooltip with supporting data in table form. As they move the cursor over the graph I want the tooltip to follow the cursor and update with new data about the area they're hovering over. I also want the tooltip to become visible when they hit the mouse button and disappear when they release the button.

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>


The finished product will look like this.

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));
            }
        }
    }
}


Step one is to get the data from the database. We will return the WorkOrder data grouped by year/month and scrap reason and store it in a data table called ScrapRates. This is done in our constructor.

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;
}


We will start by populating the static data grid with total scrap rates across our entire data set, with the most common scrap reasons listed first. We will do this by declaring a class, a list of that class, code to populate it, and a call to that code.

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();
}

This gives us the totals table. Note I'm floating it on top of the canvas instead of making it a child. This simplifies the refreshing of the canvas.


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();
}

The next thing to do is render the graph. This is pretty tedious although the bit to rotate the X-axis legend was interesting.

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) });
}


 We need to render the graph on initial load and also whenever the size of the canvas changes.

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)
{
}

We also need properties to control the position and visibility of the popup grid. Normally I would control visibility through a boolean property but I didn't want to increase the complexity by adding a converter or a trigger.

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); }
}

SetTooltipLocation is a crude attempt to make the popup track the mouse location without ever going off the canvas. We are using Mouse.Capture so we can detect when the user releases the mouse button even if they have moved the mouse off of the canvas.

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);
}

The contents of the tooltip are bound to a list of objects that are populated by LoadScrapReasonsForMonth. Note we only populate the five most common scrap reasons otherwise the popup would be too tall.

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;
}

We need to call LoadScrapReasonsForMonth whenever the user presses the mouse button or moves the mouse.

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);
}

This gives us the final result.




No comments:

Post a Comment