File: System.Activities.Presentation\System\Activities\Presentation\MiniMap\MiniMapControl.xaml.cs
Project: ndp\cdf\src\NetFx40\Tools\System.Activities.Presentation.csproj (System.Activities.Presentation)
//----------------------------------------------------------------
// Copyright (c) Microsoft Corporation.  All rights reserved.
//----------------------------------------------------------------
#if DEBUG
//#define MINIMAP_DEBUG
#endif
 
namespace System.Activities.Presentation
{
    using System;
    using System.Windows;
    using System.Windows.Controls;
    using System.Windows.Input;
    using System.Windows.Media;
    using System.Windows.Shapes;
    using System.Diagnostics;
    using System.Windows.Threading;
    using System.Globalization;
 
    // This class is a control displaying minimap of the attached scrollableview control
    // this class's functionality is limited to delegating events to minimap view controller
 
    partial class MiniMapControl : UserControl
    {
        public static readonly DependencyProperty MapSourceProperty =
                DependencyProperty.Register("MapSource",
                typeof(ScrollViewer),
                typeof(MiniMapControl),
                new FrameworkPropertyMetadata(null,
                FrameworkPropertyMetadataOptions.AffectsRender,
                new PropertyChangedCallback(OnMapSourceChanged)));
 
        MiniMapViewController lookupWindowManager;
        bool isMouseDown = false;
 
        public MiniMapControl()
        {
            InitializeComponent();
            this.lookupWindowManager = new MiniMapViewController(this.lookupCanvas, this.lookupWindow, this.contentGrid);
        }
 
        public ScrollViewer MapSource
        {
            get { return GetValue(MapSourceProperty) as ScrollViewer; }
            set { SetValue(MapSourceProperty, value); }
        }
 
        static void OnMapSourceChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
        {
            MiniMapControl mapControl = (MiniMapControl)sender;
            mapControl.lookupWindowManager.MapSource = mapControl.MapSource;
        }
 
        protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
        {
            base.OnMouseLeftButtonDown(e);
            if (this.lookupWindowManager.StartMapLookupDrag(e))
            {
                this.CaptureMouse();
                this.isMouseDown = true;
            }
        }
 
        protected override void OnMouseMove(MouseEventArgs e)
        {
            base.OnMouseMove(e);
            if (this.isMouseDown)
            {
                this.lookupWindowManager.DoMapLookupDrag(e);
            }
        }
 
        protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e)
        {
            base.OnMouseLeftButtonUp(e);
            if (this.isMouseDown)
            {
                Mouse.Capture(null);
                this.isMouseDown = false;
                this.lookupWindowManager.StopMapLookupDrag();
            }
        }
 
        protected override void OnMouseDoubleClick(MouseButtonEventArgs e)
        {
            this.lookupWindowManager.CenterView(e);
            e.Handled = true;
            base.OnMouseDoubleClick(e);
        }
 
        protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo)
        {
            base.OnRenderSizeChanged(sizeInfo);
            this.lookupWindowManager.MapViewSizeChanged(sizeInfo);
        }
 
        // This class wraps positioning and calculating logic of the map view lookup window
        // It is also responsible for handling mouse movements
 
        internal class LookupWindow
        {
            Point mousePosition;
            Rectangle lookupWindowRectangle;
            MiniMapViewController parent;
 
 
            public LookupWindow(MiniMapViewController parent, Rectangle lookupWindowRectangle)
            {
                this.mousePosition = new Point();
                this.parent = parent;
                this.lookupWindowRectangle = lookupWindowRectangle;
            }
 
            public double Left
            {
                get { return Canvas.GetLeft(this.lookupWindowRectangle); }
                set
                {
                    //check if left corner is within minimap's range - clip if necessary
                    double left = Math.Max(value - this.mousePosition.X, 0.0);
                    //check if right corner is within minimap's range - clip if necessary
                    left = (left + Width > this.parent.MapWidth ? this.parent.MapWidth - Width : left);
                    //update canvas
                    Canvas.SetLeft(this.lookupWindowRectangle, left);
                }
            }
 
            public double Top
            {
                get { return Canvas.GetTop(this.lookupWindowRectangle); }
                set
                {
                    //check if top corner is within minimap's range - clip if necessary
                    double top = Math.Max(value - this.mousePosition.Y, 0.0);
                    //check if bottom corner is within minimap's range - clip if necessary
                    top = (top + Height > this.parent.MapHeight ? this.parent.MapHeight - Height : top);
                    //update canvas
                    Canvas.SetTop(this.lookupWindowRectangle, top);
                }
            }
 
            public double Width
            {
                get { return this.lookupWindowRectangle.Width; }
                set { this.lookupWindowRectangle.Width = value; }
            }
 
            public double Height
            {
                get { return this.lookupWindowRectangle.Height; }
                set { this.lookupWindowRectangle.Height = value; }
            }
 
            public double MapCenterXPoint
            {
                get { return this.Left + (this.Width / 2.0); }
            }
 
            public double MapCenterYPoint
            {
                get { return this.Top + (this.Height / 2.0); }
            }
 
            public double MousePositionX
            {
                get { return this.mousePosition.X; }
            }
 
            public double MousePositionY
            {
                get { return this.mousePosition.Y; }
            }
 
            public bool IsSelected
            {
                get;
                private set;
            }
 
            public void SetPosition(double left, double top)
            {
                Left = left;
                Top = top;
            }
 
            public void SetSize(double width, double height)
            {
                Width = width;
                Height = height;
            }
 
            //whenever user clicks on the minimap, i check if clicked object is 
            //a lookup window - if yes - i store mouse offset within the window
            //and mark it as selected
            public bool Select(object clickedItem, Point clickedPosition)
            {
                if (clickedItem == this.lookupWindowRectangle)
                {
                    this.mousePosition = clickedPosition;
                    this.IsSelected = true;
                }
                else
                {
                    Unselect();
                }
                return this.IsSelected;
            }
 
            public void Unselect()
            {
                this.mousePosition.X = 0;
                this.mousePosition.Y = 0;
                this.IsSelected = false;
            }
 
            public void Center(double x, double y)
            {
                Left = x - (Width / 2.0);
                Top = y - (Height / 2.0);
            }
 
            public void Refresh(bool unselect)
            {
                if (unselect)
                {
                    Unselect();
                }
                SetPosition(Left, Top);
            }
        }
 
        // This class is responsible for calculating size of the minimap's view area, as well as
        // maintaining the bi directional link between minimap and control beeing visualized.
        // Whenever minimap's view window position is updated, the control's content is scrolled 
        // to calculated position
        // Whenever control's content is resized or scrolled, minimap reflects that change in 
        // recalculating view's window size and/or position
 
        internal class MiniMapViewController
        {
            Canvas lookupCanvas;
            Grid contentGrid;
            ScrollViewer mapSource;
            LookupWindow lookupWindow;
 
            public MiniMapViewController(Canvas lookupCanvas, Rectangle lookupWindowRectangle, Grid contentGrid)
            {
                this.lookupWindow = new LookupWindow(this, lookupWindowRectangle);
                this.lookupCanvas = lookupCanvas;
                this.contentGrid = contentGrid;
            }
 
            public ScrollViewer MapSource
            {
                get { return this.mapSource; }
                set
                {
                    this.mapSource = value;
                    //calculate view's size and set initial position
                    this.lookupWindow.Unselect();
                    this.CalculateLookupWindowSize();
                    this.lookupWindow.SetPosition(0.0, 0.0);
                    CalculateMapPosition(this.lookupWindow.Left, this.lookupWindow.Top);
                    this.UpdateContentGrid();
 
                    if (null != this.mapSource && null != this.mapSource.Content && this.mapSource.Content is FrameworkElement)
                    {
                        FrameworkElement content = (FrameworkElement)this.mapSource.Content;
                        //hook up for all content size changes - handle them in OnContentSizeChanged method
                        content.SizeChanged += (s, e) =>
                            {
                                this.contentGrid.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle,
                                    new Action(() => { OnContentSizeChanged(s, e); }));
                            };
 
                        //in case of scroll viewer - there are two different events to handle in one notification:
                        this.mapSource.ScrollChanged += (s, e) =>
                        {
                            this.contentGrid.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle,
                                new Action(() =>
                                    {
                                        //when user changes scroll position - delegate it to OnMapSourceScrollChange
                                        if (0.0 != e.HorizontalChange || 0.0 != e.VerticalChange)
                                        {
                                            OnMapSourceScrollChanged(s, e);
                                        }
                                        //when size of the scroll changes delegate it to OnContentSizeChanged
                                        if (0.0 != e.ViewportWidthChange || 0.0 != e.ViewportHeightChange)
                                        {
                                            OnContentSizeChanged(s, e);
                                        }
                                    }));
                        };
                        this.OnMapSourceScrollChanged(this, null);
                        this.OnContentSizeChanged(this, null);
                    }
                }
            }
 
            //bunch of helper getters - used to increase algorithm readability and provide default
            //values, always valid values, so no additional divide-by-zero checks are neccessary
 
            public double MapWidth
            {
                get { return this.contentGrid.ActualWidth - 2 * (this.contentGrid.ColumnDefinitions[0].MinWidth); }
            }
 
            public double MapHeight
            {
                get { return this.contentGrid.ActualHeight - 2 * (this.contentGrid.RowDefinitions[0].MinHeight); }
            }
 
            internal LookupWindow LookupWindow
            {
                get { return this.lookupWindow; }
            }
 
            double VisibleSourceWidth
            {
                get { return (null == MapSource || 0.0 == MapSource.ViewportWidth ? 1.0 : MapSource.ViewportWidth); }
            }
 
            double VisibleSourceHeight
            {
                get { return (null == MapSource || 0.0 == MapSource.ViewportHeight ? 1.0 : MapSource.ViewportHeight); }
            }
 
 
            public void CenterView(MouseEventArgs args)
            {
                Point pt = args.GetPosition(this.lookupCanvas);
                this.lookupWindow.Unselect();
                this.lookupWindow.Center(pt.X, pt.Y);
                CalculateMapPosition(this.lookupWindow.Left, this.lookupWindow.Top);
            }
 
            public void MapViewSizeChanged(SizeChangedInfo sizeInfo)
            {
                this.OnContentSizeChanged(this, EventArgs.Empty);
                this.lookupWindow.Unselect();
                this.CalculateLookupWindowSize();
                if (sizeInfo.WidthChanged && 0.0 != sizeInfo.PreviousSize.Width)
                {
                    this.lookupWindow.Left =
                        this.lookupWindow.Left * (sizeInfo.NewSize.Width / sizeInfo.PreviousSize.Width);
                }
                if (sizeInfo.HeightChanged && 0.0 != sizeInfo.PreviousSize.Height)
                {
                    this.lookupWindow.Top =
                        this.lookupWindow.Top * (sizeInfo.NewSize.Height / sizeInfo.PreviousSize.Height);
                }
            }
 
            public bool StartMapLookupDrag(MouseEventArgs args)
            {
                bool result = false;
                HitTestResult hitTest =
                    VisualTreeHelper.HitTest(this.lookupCanvas, args.GetPosition(this.lookupCanvas));
 
                if (null != hitTest && null != hitTest.VisualHit)
                {
                    Point clickedPosition = args.GetPosition(hitTest.VisualHit as IInputElement);
                    result = this.lookupWindow.Select(hitTest.VisualHit, clickedPosition);
                }
                return result;
            }
 
            public void StopMapLookupDrag()
            {
                this.lookupWindow.Unselect();
            }
 
            public void DoMapLookupDrag(MouseEventArgs args)
            {
                if (args.LeftButton == MouseButtonState.Released && this.lookupWindow.IsSelected)
                {
                    this.lookupWindow.Unselect();
                }
                if (this.lookupWindow.IsSelected)
                {
                    Point to = args.GetPosition(this.lookupCanvas);
                    this.lookupWindow.SetPosition(to.X, to.Y);
                    CalculateMapPosition(
                        to.X - this.lookupWindow.MousePositionX,
                        to.Y - this.lookupWindow.MousePositionY);
                }
            }
 
            void CalculateMapPosition(double left, double top)
            {
                if (null != MapSource && 0 != this.lookupWindow.Width && 0 != this.lookupWindow.Height)
                {
                    MapSource.ScrollToHorizontalOffset((left / this.lookupWindow.Width) * VisibleSourceWidth);
                    MapSource.ScrollToVerticalOffset((top / this.lookupWindow.Height) * VisibleSourceHeight);
                }
            }
 
            //this method calculates position of the lookup window on the minimap - it should be triggered when:
            // - user modifies a scroll position by draggin a scroll bar
            // - scroll sizes are updated by change of the srcollviewer size
            // - user drags minimap view - however, in this case no lookup update takes place
            void OnMapSourceScrollChanged(object sender, ScrollChangedEventArgs e)
            {
                if (!this.lookupWindow.IsSelected && null != MapSource)
                {
                    this.lookupWindow.Unselect();
                    this.lookupWindow.Left =
                        this.lookupWindow.Width * (MapSource.HorizontalOffset / VisibleSourceWidth);
 
                    this.lookupWindow.Top =
                        this.lookupWindow.Height * (MapSource.VerticalOffset / VisibleSourceHeight);
                }
                DumpData("OnMapSourceScrollChange");
            }
 
            //this method calculates size and position of the minimap view - it should be triggered when:
            // - zoom changes
            // - visible size of the scrollviewer (which is map source) changes
            // - visible size of the minimap control changes 
            void OnContentSizeChanged(object sender, EventArgs e)
            {
                //get old center point coordinates
                double centerX = this.lookupWindow.MapCenterXPoint;
                double centeryY = this.lookupWindow.MapCenterYPoint;
                //update the minimap itself
                this.UpdateContentGrid();
                //calculate new size
                this.CalculateLookupWindowSize();
                //try to center around old center points (window may be moved if doesn't fit)
                this.lookupWindow.Center(centerX, centeryY);
                DumpData("OnContentSizeChanged");
            }
 
            //this method calculates size of the lookup rectangle, based on the visible size of the object, 
            //including current map width
            void CalculateLookupWindowSize()
            {
                double width = this.MapWidth;
                double height = this.MapHeight;
 
                if (this.MapSource.ScrollableWidth != 0 && this.MapSource.ExtentWidth != 0)
                {
                    width = (this.MapSource.ViewportWidth / this.MapSource.ExtentWidth) * this.MapWidth;
                }
                else
                {
                    //width = 
                }
                if (this.MapSource.ScrollableHeight != 0 && this.MapSource.ExtentHeight != 0)
                {
                    height = (this.MapSource.ViewportHeight / this.MapSource.ExtentHeight) * this.MapHeight;
                }
                this.lookupWindow.SetSize(width, height);
            }
 
            //this method updates content grid of the minimap - most likely, minimap view will be scaled to fit
            //the window - so there will be some extra space visible on the left and right sides or above and below actual
            //mini map view - we don't want lookup rectangle to navigate within that area, since it is not representing
            //actual view - we increase margins of the minimap to disallow this
            void UpdateContentGrid()
            {
                bool resetToDefault = true;
                if (this.MapSource.ExtentWidth != 0 && this.MapSource.ExtentHeight != 0)
                {
                    //get width to height ratio from map source - we want to display our minimap in the same ratio
                    double widthToHeightRatio = this.MapSource.ExtentWidth / this.MapSource.ExtentHeight;
 
                    //calculate current width to height ratio on the minimap
                    double height = this.contentGrid.ActualHeight;
                    double width = this.contentGrid.ActualWidth;
                    //ideally - it should be 1 - whole view perfectly fits minimap 
                    double minimapWidthToHeightRatio = (height * widthToHeightRatio) / (width > 1.0 ? width : 1.0);
 
                    //if value is greater than one - we have to reduce height
                    if (minimapWidthToHeightRatio > 1.0)
                    {
                        double margin = (height - (height / minimapWidthToHeightRatio)) / 2.0;
 
                        this.contentGrid.ColumnDefinitions[0].MinWidth = 0.0;
                        this.contentGrid.ColumnDefinitions[2].MinWidth = 0.0;
                        this.contentGrid.RowDefinitions[0].MinHeight = margin;
                        this.contentGrid.RowDefinitions[2].MinHeight = margin;
                        resetToDefault = false;
                    }
                    //if value is less than one - we have to reduce width
                    else if (minimapWidthToHeightRatio < 1.0)
                    {
                        double margin = (width - (width * minimapWidthToHeightRatio)) / 2.0;
                        this.contentGrid.ColumnDefinitions[0].MinWidth = margin;
                        this.contentGrid.ColumnDefinitions[2].MinWidth = margin;
                        this.contentGrid.RowDefinitions[0].MinHeight = 0.0;
                        this.contentGrid.RowDefinitions[2].MinHeight = 0.0;
                        resetToDefault = false;
                    }
                }
                //perfect match or nothing to display - no need to setup margins
                if (resetToDefault)
                {
                    this.contentGrid.ColumnDefinitions[0].MinWidth = 0.0;
                    this.contentGrid.ColumnDefinitions[2].MinWidth = 0.0;
                    this.contentGrid.RowDefinitions[0].MinHeight = 0.0;
                    this.contentGrid.RowDefinitions[2].MinHeight = 0.0;
                }
            }
 
            [Conditional("MINIMAP_DEBUG")]
            void DumpData(string prefix)
            {
                System.Diagnostics.Debug.WriteLine(string.Format(CultureInfo.InvariantCulture, "{0} ScrollViewer: EWidth {1}, EHeight {2}, AWidth {3}, AHeight {4}, ViewPortW {5} ViewPortH {6}", prefix, mapSource.ExtentWidth, mapSource.ExtentHeight, mapSource.ActualWidth, mapSource.ActualHeight, mapSource.ViewportWidth, mapSource.ViewportHeight));
            }
 
        }
    }
}