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