diff --git a/MapControl/Avalonia/ImageLoader.Avalonia.cs b/MapControl/Avalonia/ImageLoader.Avalonia.cs new file mode 100644 index 00000000..928df8fa --- /dev/null +++ b/MapControl/Avalonia/ImageLoader.Avalonia.cs @@ -0,0 +1,45 @@ +// XAML Map Control - https://github.com/ClemensFischer/XAML-Map-Control +// Copyright © 2024 Clemens Fischer +// Licensed under the Microsoft Public License (Ms-PL) + +using Avalonia.Media; +using Avalonia.Media.Imaging; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace MapControl +{ + public static partial class ImageLoader + { + public static IImage LoadImage(Uri uri) + { + return null; + } + + public static IImage LoadImage(Stream stream) + { + return new Bitmap(stream); + } + + public static Task LoadImageAsync(Stream stream) + { + return Task.FromResult(LoadImage(stream)); + } + + public static Task LoadImageAsync(string path) + { + return Task.Run(() => + { + if (!File.Exists(path)) + { + return null; + } + + using var stream = File.OpenRead(path); + + return LoadImage(stream); + }); + } + } +} diff --git a/MapControl/Avalonia/Map.cs b/MapControl/Avalonia/Map.cs new file mode 100644 index 00000000..6355a19e --- /dev/null +++ b/MapControl/Avalonia/Map.cs @@ -0,0 +1,96 @@ +// XAML Map Control - https://github.com/ClemensFischer/XAML-Map-Control +// Copyright © 2024 Clemens Fischer +// Licensed under the Microsoft Public License (Ms-PL) + +using Avalonia.Input; +using System; +using System.Threading; + +namespace MapControl +{ + /// + /// MapBase with default input event handling. + /// + public class Map : MapBase + { + public static readonly StyledProperty MouseWheelZoomDeltaProperty + = AvaloniaProperty.Register(nameof(MouseWheelZoomDelta), 0.25); + + private Point? mousePosition; + private double targetZoomLevel; + private CancellationTokenSource cancellationTokenSource; + + public Map() + { + PointerWheelChanged += OnPointerWheelChanged; + } + + /// + /// Gets or sets the amount by which the ZoomLevel property changes by a MouseWheel event. + /// The default value is 0.25. + /// + public double MouseWheelZoomDelta + { + get => GetValue(MouseWheelZoomDeltaProperty); + set => SetValue(MouseWheelZoomDeltaProperty, value); + } + + private async void OnPointerWheelChanged(object sender, PointerWheelEventArgs e) + { + var delta = MouseWheelZoomDelta * e.Delta.Y; + + if (cancellationTokenSource != null) + { + cancellationTokenSource?.Cancel(); + targetZoomLevel += delta; + } + else + { + targetZoomLevel = ZoomLevel + delta; + } + + cancellationTokenSource = new CancellationTokenSource(); + + await ZoomMap(e.GetPosition(this), targetZoomLevel, cancellationTokenSource.Token); + + cancellationTokenSource.Dispose(); + cancellationTokenSource = null; + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + var point = e.GetCurrentPoint(this); + + if (point.Properties.IsLeftButtonPressed) + { + e.Pointer.Capture(this); + mousePosition = point.Position; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + + if (mousePosition.HasValue) + { + e.Pointer.Capture(null); + mousePosition = null; + } + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + + if (mousePosition.HasValue) + { + var position = e.GetPosition(this); + TranslateMap(position - mousePosition.Value); + mousePosition = position; + } + } + } +} diff --git a/MapControl/Avalonia/MapBase.cs b/MapControl/Avalonia/MapBase.cs new file mode 100644 index 00000000..a3a873c3 --- /dev/null +++ b/MapControl/Avalonia/MapBase.cs @@ -0,0 +1,564 @@ +// XAML Map Control - https://github.com/ClemensFischer/XAML-Map-Control +// Copyright © 2024 Clemens Fischer +// Licensed under the Microsoft Public License (Ms-PL) + +global using Avalonia; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Media; +using Avalonia.Styling; +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace MapControl +{ + public interface IMapLayer + { + IBrush MapBackground { get; } + IBrush MapForeground { get; } + } + + public class MapBase : MapPanel + { + public static TimeSpan ImageFadeDuration { get; set; } = TimeSpan.FromSeconds(0.1); + + public static readonly StyledProperty ForegroundProperty + = AvaloniaProperty.Register(nameof(Foreground)); + + public static readonly StyledProperty AnimationDurationProperty + = AvaloniaProperty.Register(nameof(AnimationDuration), TimeSpan.FromSeconds(0.3)); + + public static readonly StyledProperty MapLayerProperty + = AvaloniaProperty.Register(nameof(MapLayer)); + + public static readonly StyledProperty MapProjectionProperty + = AvaloniaProperty.Register(nameof(MapProjection), new WebMercatorProjection()); + + public static readonly StyledProperty ProjectionCenterProperty + = AvaloniaProperty.Register(nameof(ProjectionCenter)); + + public static readonly StyledProperty CenterProperty + = AvaloniaProperty.Register(nameof(Center), new Location(), false, + BindingMode.TwoWay, null, (map, center) => ((MapBase)map).CoerceCenterProperty(center)); + + public static readonly StyledProperty MinZoomLevelProperty + = AvaloniaProperty.Register(nameof(MinZoomLevel), 1d, false, + BindingMode.OneWay, null, (map, minZoomLevel) => ((MapBase)map).CoerceMinZoomLevelProperty(minZoomLevel)); + + public static readonly StyledProperty MaxZoomLevelProperty + = AvaloniaProperty.Register(nameof(MaxZoomLevel), 20d, false, + BindingMode.OneWay, null, (map, maxZoomLevel) => ((MapBase)map).CoerceMaxZoomLevelProperty(maxZoomLevel)); + + public static readonly StyledProperty ZoomLevelProperty + = AvaloniaProperty.Register(nameof(ZoomLevel), 1d, false, + BindingMode.TwoWay, null, (map, zoomLevel) => ((MapBase)map).CoerceZoomLevelProperty(zoomLevel)); + + public static readonly StyledProperty HeadingProperty + = AvaloniaProperty.Register(nameof(Heading), 0d, false, + BindingMode.TwoWay, null, (map, heading) => ((heading % 360d) + 360d) % 360d); + + public static readonly DirectProperty ViewScaleProperty + = AvaloniaProperty.RegisterDirect(nameof(ViewScale), map => map.ViewScale); + + private Location transformCenter; + private Point viewCenter; + private double centerLongitude; + private double maxLatitude = 90d; + + static MapBase() + { + MapLayerProperty.Changed.AddClassHandler( + (map, args) => map.MapLayerPropertyChanged(args)); + + MapProjectionProperty.Changed.AddClassHandler( + (map, args) => map.MapProjectionPropertyChanged(args.NewValue.Value)); + + ProjectionCenterProperty.Changed.AddClassHandler( + (map, args) => map.ProjectionCenterPropertyChanged()); + + CenterProperty.Changed.AddClassHandler( + (map, args) => map.UpdateTransform()); + + ZoomLevelProperty.Changed.AddClassHandler( + (map, args) => map.UpdateTransform()); + + HeadingProperty.Changed.AddClassHandler( + (map, args) => map.UpdateTransform()); + } + + public MapBase() + { + MapProjectionPropertyChanged(MapProjection); + } + + /// + /// Raised when the current map viewport has changed. + /// + public event EventHandler ViewportChanged; + + /// + /// Gets or sets the map foreground Brush. + /// + public IBrush Foreground + { + get => GetValue(ForegroundProperty); + set => SetValue(ForegroundProperty, value); + } + + /// + /// Gets or sets the Duration of the Center, ZoomLevel and Heading animations. + /// The default value is 0.3 seconds. + /// + public TimeSpan AnimationDuration + { + get => GetValue(AnimationDurationProperty); + set => SetValue(AnimationDurationProperty, value); + } + + /// + /// Gets or sets the base map layer, which is added as first element to the Children collection. + /// If the layer implements IMapLayer (like MapTileLayer or MapImageLayer), its (non-null) MapBackground + /// and MapForeground property values are used for the MapBase Background and Foreground properties. + /// + public Control MapLayer + { + get => GetValue(MapLayerProperty); + set => SetValue(MapLayerProperty, value); + } + + /// + /// Gets or sets the MapProjection used by the map control. + /// + public MapProjection MapProjection + { + get => GetValue(MapProjectionProperty); + set => SetValue(MapProjectionProperty, value); + } + + /// + /// Gets or sets an optional center (reference point) for azimuthal projections. + /// If ProjectionCenter is null, the Center property value will be used instead. + /// + public Location ProjectionCenter + { + get => GetValue(ProjectionCenterProperty); + set => SetValue(ProjectionCenterProperty, value); + } + + /// + /// Gets or sets the location of the center point of the map. + /// + public Location Center + { + get => GetValue(CenterProperty); + set => SetValue(CenterProperty, value); + } + + /// + /// Gets or sets the minimum value of the ZoomLevel property. + /// Must not be less than zero or greater than MaxZoomLevel. The default value is 1. + /// + public double MinZoomLevel + { + get => GetValue(MinZoomLevelProperty); + set => SetValue(MinZoomLevelProperty, value); + } + + /// + /// Gets or sets the maximum value of the ZoomLevel property. + /// Must not be less than MinZoomLevel. The default value is 20. + /// + public double MaxZoomLevel + { + get => GetValue(MaxZoomLevelProperty); + set => SetValue(MaxZoomLevelProperty, value); + } + + /// + /// Gets or sets the map zoom level. + /// + public double ZoomLevel + { + get => GetValue(ZoomLevelProperty); + set => SetValue(ZoomLevelProperty, value); + } + + /// + /// Gets or sets the map heading, a counter-clockwise rotation angle in degrees. + /// + public double Heading + { + get => GetValue(HeadingProperty); + set => SetValue(HeadingProperty, value); + } + + /// + /// Gets the scaling factor from projected map coordinates to view coordinates, + /// as pixels per meter. + /// + public double ViewScale + { + get => ViewTransform.Scale; + } + + /// + /// Gets the ViewTransform instance that is used to transform between projected + /// map coordinates and view coordinates. + /// + public ViewTransform ViewTransform { get; } = new ViewTransform(); + + /// + /// Gets the map scale as the horizontal and vertical scaling factors from geographic + /// coordinates to view coordinates at the specified location, as pixels per meter. + /// + public Point GetScale(Location location) + { + return MapProjection.GetRelativeScale(location) * ViewTransform.Scale; + } + + /// + /// Gets a transform Matrix for scaling and rotating objects that are anchored + /// at a Location from map coordinates (i.e. meters) to view coordinates. + /// + public Matrix GetMapTransform(Location location) + { + var scale = GetScale(location); + + return new Matrix(scale.X, 0d, 0d, scale.Y, 0d, 0d) + .Append(Matrix.CreateRotation(ViewTransform.Rotation)); + } + + /// + /// Transforms a Location in geographic coordinates to a Point in view coordinates. + /// + public Point? LocationToView(Location location) + { + var point = MapProjection.LocationToMap(location); + + return point.HasValue ? ViewTransform.MapToView(point.Value) : null; + } + + /// + /// Transforms a Point in view coordinates to a Location in geographic coordinates. + /// + public Location ViewToLocation(Point point) + { + return MapProjection.MapToLocation(ViewTransform.ViewToMap(point)); + } + + /// + /// Transforms a Rect in view coordinates to a BoundingBox in geographic coordinates. + /// + public BoundingBox ViewRectToBoundingBox(Rect rect) + { + var p1 = ViewTransform.ViewToMap(new Point(rect.X, rect.Y)); + var p2 = ViewTransform.ViewToMap(new Point(rect.X, rect.Y + rect.Height)); + var p3 = ViewTransform.ViewToMap(new Point(rect.X + rect.Width, rect.Y)); + var p4 = ViewTransform.ViewToMap(new Point(rect.X + rect.Width, rect.Y + rect.Height)); + + var x1 = Math.Min(p1.X, Math.Min(p2.X, Math.Min(p3.X, p4.X))); + var y1 = Math.Min(p1.Y, Math.Min(p2.Y, Math.Min(p3.Y, p4.Y))); + var x2 = Math.Max(p1.X, Math.Max(p2.X, Math.Max(p3.X, p4.X))); + var y2 = Math.Max(p1.Y, Math.Max(p2.Y, Math.Max(p3.Y, p4.Y))); + + return MapProjection.MapToBoundingBox(new Rect(x1, y1, x2, y2)); + } + + /// + /// Sets a temporary center point in view coordinates for scaling and rotation transformations. + /// This center point is automatically reset when the Center property is set by application code + /// or by the methods TranslateMap, TransformMap, ZoomMap and ZoomToBounds. + /// + public void SetTransformCenter(Point center) + { + transformCenter = ViewToLocation(center); + viewCenter = transformCenter != null ? center : new Point(Bounds.Width / 2d, Bounds.Height / 2d); + } + + /// + /// Resets the temporary transform center point set by SetTransformCenter. + /// + public void ResetTransformCenter() + { + transformCenter = null; + viewCenter = new Point(Bounds.Width / 2d, Bounds.Height / 2d); + } + + /// + /// Changes the Center property according to the specified translation in view coordinates. + /// + public void TranslateMap(Point translation) + { + if (transformCenter != null) + { + ResetTransformCenter(); + UpdateTransform(); + } + + if (translation.X != 0d || translation.Y != 0d) + { + var center = ViewToLocation(new Point(viewCenter.X - translation.X, viewCenter.Y - translation.Y)); + + if (center != null) + { + Center = center; + } + } + } + + /// + /// Changes the Center, Heading and ZoomLevel properties according to the specified + /// view coordinate translation, rotation and scale delta values. Rotation and scaling + /// is performed relative to the specified center point in view coordinates. + /// + public void TransformMap(Point center, Point translation, double rotation, double scale) + { + if (rotation != 0d || scale != 1d) + { + SetTransformCenter(center); + + viewCenter += translation; + + if (rotation != 0d) + { + Heading = (((Heading - rotation) % 360d) + 360d) % 360d; + } + + if (scale != 1d) + { + ZoomLevel = Math.Min(Math.Max(ZoomLevel + Math.Log(scale, 2d), MinZoomLevel), MaxZoomLevel); + } + + UpdateTransform(true); + } + else + { + // More accurate than SetTransformCenter. + // + TranslateMap(translation); + } + } + + /// + /// Animates the value of the ZoomLevel property while retaining the specified center point + /// in view coordinates. + /// + public async Task ZoomMap(Point center, double zoomLevel, CancellationToken cancellationToken = default) + { + zoomLevel = Math.Min(Math.Max(zoomLevel, MinZoomLevel), MaxZoomLevel); + + if (zoomLevel != ZoomLevel) + { + SetTransformCenter(center); + + var animation = new Animation + { + FillMode = FillMode.Forward, + Duration = AnimationDuration, + Children = + { + new KeyFrame + { + KeyTime = AnimationDuration, + Setters = { new Setter(ZoomLevelProperty, zoomLevel) } + } + } + }; + + await animation.RunAsync(this, cancellationToken); + } + } + + protected override void OnSizeChanged(SizeChangedEventArgs e) + { + base.OnSizeChanged(e); + + ResetTransformCenter(); + UpdateTransform(); + } + + internal double ConstrainedLongitude(double longitude) + { + var offset = longitude - Center.Longitude; + + if (offset > 180d) + { + longitude = Center.Longitude + (offset % 360d) - 360d; + } + else if (offset < -180d) + { + longitude = Center.Longitude + (offset % 360d) + 360d; + } + + return longitude; + } + + private void UpdateTransform(bool resetTransformCenter = false, bool projectionChanged = false) + { + var transformCenterChanged = false; + var oldViewScale = ViewScale; + var viewScale = ViewTransform.ZoomLevelToScale(ZoomLevel); + var projection = MapProjection; + + projection.Center = ProjectionCenter ?? Center; + + var mapCenter = projection.LocationToMap(transformCenter ?? Center); + + if (mapCenter.HasValue) + { + ViewTransform.SetTransform(mapCenter.Value, viewCenter, viewScale, -Heading); + + if (transformCenter != null) + { + var center = ViewToLocation(new Point(Bounds.Width / 2d, Bounds.Height / 2d)); + + if (center != null) + { + var centerLatitude = center.Latitude; + var centerLongitude = Location.NormalizeLongitude(center.Longitude); + + if (centerLatitude < -maxLatitude || centerLatitude > maxLatitude) + { + centerLatitude = Math.Min(Math.Max(centerLatitude, -maxLatitude), maxLatitude); + resetTransformCenter = true; + } + + Center = new Location(centerLatitude, centerLongitude); + + if (resetTransformCenter) + { + // Check if transform center has moved across 180° longitude. + // + transformCenterChanged = Math.Abs(center.Longitude - transformCenter.Longitude) > 180d; + + ResetTransformCenter(); + + projection.Center = ProjectionCenter ?? Center; + + mapCenter = projection.LocationToMap(center); + + if (mapCenter.HasValue) + { + ViewTransform.SetTransform(mapCenter.Value, viewCenter, viewScale, -Heading); + } + } + } + } + + RaisePropertyChanged(ViewScaleProperty, oldViewScale, ViewScale); + + // Check if view center has moved across 180° longitude. + // + transformCenterChanged = transformCenterChanged || Math.Abs(Center.Longitude - centerLongitude) > 180d; + centerLongitude = Center.Longitude; + + OnViewportChanged(new ViewportChangedEventArgs(projectionChanged, transformCenterChanged)); + } + } + + protected override void OnViewportChanged(ViewportChangedEventArgs e) + { + base.OnViewportChanged(e); + + ViewportChanged?.Invoke(this, e); + } + + private void MapLayerPropertyChanged(AvaloniaPropertyChangedEventArgs args) + { + if (args.OldValue.Value != null) + { + Children.Remove(args.OldValue.Value); + + if (args.OldValue.Value is IMapLayer mapLayer) + { + if (mapLayer.MapBackground != null) + { + ClearValue(BackgroundProperty); + } + if (mapLayer.MapForeground != null) + { + ClearValue(ForegroundProperty); + } + } + } + + if (args.NewValue.Value != null) + { + Children.Insert(0, args.NewValue.Value); + + if (args.NewValue.Value is IMapLayer mapLayer) + { + if (mapLayer.MapBackground != null) + { + Background = mapLayer.MapBackground; + } + if (mapLayer.MapForeground != null) + { + Foreground = mapLayer.MapForeground; + } + } + } + } + + private void MapProjectionPropertyChanged(MapProjection projection) + { + maxLatitude = 90d; + + if (projection.Type <= MapProjectionType.NormalCylindrical) + { + var maxLocation = projection.MapToLocation(new Point(0d, 180d * MapProjection.Wgs84MeterPerDegree)); + + if (maxLocation != null && maxLocation.Latitude < 90d) + { + maxLatitude = maxLocation.Latitude; + Center = CoerceCenterProperty(Center); + } + } + + ResetTransformCenter(); + UpdateTransform(false, true); + } + + private void ProjectionCenterPropertyChanged() + { + ResetTransformCenter(); + UpdateTransform(); + } + + private Location CoerceCenterProperty(Location center) + { + if (center == null) + { + center = new Location(); + } + else if ( + center.Latitude < -maxLatitude || center.Latitude > maxLatitude || + center.Longitude < -180d || center.Longitude > 180d) + { + center = new Location( + Math.Min(Math.Max(center.Latitude, -maxLatitude), maxLatitude), + Location.NormalizeLongitude(center.Longitude)); + } + + return center; + } + + private double CoerceMinZoomLevelProperty(double minZoomLevel) + { + return Math.Min(Math.Max(minZoomLevel, 0d), MaxZoomLevel); + } + + private double CoerceMaxZoomLevelProperty(double maxZoomLevel) + { + return Math.Max(maxZoomLevel, MinZoomLevel); + } + + private double CoerceZoomLevelProperty(double zoomLevel) + { + return Math.Min(Math.Max(zoomLevel, MinZoomLevel), MaxZoomLevel); + } + } +} diff --git a/MapControl/Avalonia/MapControl.Avalonia.csproj b/MapControl/Avalonia/MapControl.Avalonia.csproj new file mode 100644 index 00000000..580e84d7 --- /dev/null +++ b/MapControl/Avalonia/MapControl.Avalonia.csproj @@ -0,0 +1,41 @@ + + + net8.0 + disable + MapControl + XAML Map Control Library for Avalonia UI + XAML Map Control + 10.0.0 + Clemens Fischer + Copyright © 2024 Clemens Fischer + true + ..\..\MapControl.snk + false + false + XAML.MapControl + AVALONIA + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MapControl/Avalonia/MapPanel.cs b/MapControl/Avalonia/MapPanel.cs new file mode 100644 index 00000000..ed6d0f54 --- /dev/null +++ b/MapControl/Avalonia/MapPanel.cs @@ -0,0 +1,344 @@ +// XAML Map Control - https://github.com/ClemensFischer/XAML-Map-Control +// Copyright © 2024 Clemens Fischer +// Licensed under the Microsoft Public License (Ms-PL) + +using Avalonia.Controls; +using Avalonia.Media; +using System; + +namespace MapControl +{ + public class MapPanel : Panel + { + public static readonly AttachedProperty ParentMapProperty + = AvaloniaProperty.RegisterAttached("ParentMap", null, true); + + private static readonly AttachedProperty ViewPositionProperty + = AvaloniaProperty.RegisterAttached("ViewPosition"); + + public static readonly AttachedProperty LocationProperty + = AvaloniaProperty.RegisterAttached("Location"); + + public static readonly AttachedProperty BoundingBoxProperty + = AvaloniaProperty.RegisterAttached("BoundingBox"); + + public static readonly AttachedProperty AutoCollapseProperty + = AvaloniaProperty.RegisterAttached("AutoCollapse"); + + static MapPanel() + { + ParentMapProperty.Changed.AddClassHandler( + (panel, args) => panel.OnParentMapPropertyChanged(args.NewValue.Value)); + } + + public MapPanel() + { + ClipToBounds = true; + + if (this is MapBase mapBase) + { + SetParentMap(this, mapBase); + } + } + + public MapBase ParentMap { get; private set; } + + public static MapBase GetParentMap(AvaloniaObject obj) => obj.GetValue(ParentMapProperty); + + private static void SetParentMap(AvaloniaObject obj, MapBase value) => obj.SetValue(ParentMapProperty, value); + + public static Point? GetViewPosition(AvaloniaObject obj) => obj.GetValue(ViewPositionProperty); + + private static void SetViewPosition(AvaloniaObject obj, Point? value) => obj.SetValue(ViewPositionProperty, value); + + public static Location GetLocation(AvaloniaObject obj) => obj.GetValue(LocationProperty); + + public static void SetLocation(AvaloniaObject obj, Location value) => obj.SetValue(LocationProperty, value); + + public static BoundingBox GetBoundingBox(AvaloniaObject obj) => obj.GetValue(BoundingBoxProperty); + + public static void SetBoundingBox(AvaloniaObject obj, BoundingBox value) => obj.SetValue(BoundingBoxProperty, value); + + public static bool GetAutoCollapse(AvaloniaObject obj) => obj.GetValue(AutoCollapseProperty); + + public static void SetAutoCollapse(AvaloniaObject obj, bool value) => obj.SetValue(AutoCollapseProperty, value); + + protected virtual void OnViewportChanged(ViewportChangedEventArgs e) + { + InvalidateArrange(); + } + + protected override Size MeasureOverride(Size availableSize) + { + availableSize = new Size(double.PositiveInfinity, double.PositiveInfinity); + + foreach (var element in Children) + { + element.Measure(availableSize); + } + + return new Size(); + } + + protected override Size ArrangeOverride(Size finalSize) + { + if (ParentMap != null) + { + foreach (var element in Children) + { + var location = GetLocation(element); + var position = location != null ? GetViewPosition(location) : null; + + SetViewPosition(element, position); + + if (GetAutoCollapse(element)) + { + element.IsVisible = !(position.HasValue && IsOutsideViewport(position.Value)); + } + + if (position.HasValue) + { + ArrangeElement(element, position.Value); + } + else + { + var boundingBox = GetBoundingBox(element); + + if (boundingBox != null) + { + var viewRect = GetViewRect(boundingBox); + + if (viewRect.HasValue) + { + ArrangeElement(element, viewRect.Value); + } + } + else + { + ArrangeElement(element, finalSize); + } + } + } + } + + return finalSize; + } + + protected Point? GetViewPosition(Location location) + { + var position = ParentMap.LocationToView(location); + + if (ParentMap.MapProjection.Type <= MapProjectionType.NormalCylindrical && + position.HasValue && + IsOutsideViewport(position.Value)) + { + position = ParentMap.LocationToView( + new Location(location.Latitude, ParentMap.ConstrainedLongitude(location.Longitude))); + } + + return position; + } + + protected ViewRect? GetViewRect(BoundingBox boundingBox) + { + var rect = ParentMap.MapProjection.BoundingBoxToMap(boundingBox); + + if (!rect.HasValue) + { + return null; + } + + return GetViewRect(rect.Value); + } + + protected ViewRect GetViewRect(Rect mapRect) + { + var position = ParentMap.ViewTransform.MapToView(mapRect.Center); + + if (ParentMap.MapProjection.Type <= MapProjectionType.NormalCylindrical && + IsOutsideViewport(position)) + { + var location = ParentMap.MapProjection.MapToLocation(mapRect.Center); + + if (location != null) + { + var pos = ParentMap.LocationToView( + new Location(location.Latitude, ParentMap.ConstrainedLongitude(location.Longitude))); + + if (pos.HasValue) + { + position = pos.Value; + } + } + } + + var width = mapRect.Width * ParentMap.ViewTransform.Scale; + var height = mapRect.Height * ParentMap.ViewTransform.Scale; + var x = position.X - width / 2d; + var y = position.Y - height / 2d; + + return new ViewRect(x, y, width, height, ParentMap.ViewTransform.Rotation); + } + + private bool IsOutsideViewport(Point point) + { + return point.X < 0d || point.X > ParentMap.Bounds.Width + || point.Y < 0d || point.Y > ParentMap.Bounds.Height; + } + + private static void ArrangeElement(Control element, Point position) + { + var size = GetDesiredSize(element); + var x = position.X; + var y = position.Y; + + switch (element.HorizontalAlignment) + { + case Avalonia.Layout.HorizontalAlignment.Center: + x -= size.Width / 2d; + break; + + case Avalonia.Layout.HorizontalAlignment.Right: + x -= size.Width; + break; + + default: + break; + } + + switch (element.VerticalAlignment) + { + case Avalonia.Layout.VerticalAlignment.Center: + y -= size.Height / 2d; + break; + + case Avalonia.Layout.VerticalAlignment.Bottom: + y -= size.Height; + break; + + default: + break; + } + + ArrangeElement(element, new Rect(x, y, size.Width, size.Height)); + } + + private static void ArrangeElement(Control element, Size parentSize) + { + var size = GetDesiredSize(element); + var x = 0d; + var y = 0d; + var width = size.Width; + var height = size.Height; + + switch (element.HorizontalAlignment) + { + case Avalonia.Layout.HorizontalAlignment.Center: + x = (parentSize.Width - size.Width) / 2d; + break; + + case Avalonia.Layout.HorizontalAlignment.Right: + x = parentSize.Width - size.Width; + break; + + case Avalonia.Layout.HorizontalAlignment.Stretch: + width = parentSize.Width; + break; + + default: + break; + } + + switch (element.VerticalAlignment) + { + case Avalonia.Layout.VerticalAlignment.Center: + y = (parentSize.Height - size.Height) / 2d; + break; + + case Avalonia.Layout.VerticalAlignment.Bottom: + y = parentSize.Height - size.Height; + break; + + case Avalonia.Layout.VerticalAlignment.Stretch: + height = parentSize.Height; + break; + + default: + break; + } + + ArrangeElement(element, new Rect(x, y, width, height)); + } + + private static void ArrangeElement(Control element, ViewRect rect) + { + element.Width = rect.Rect.Width; + element.Height = rect.Rect.Height; + + ArrangeElement(element, rect.Rect); + + if (element.RenderTransform is RotateTransform rotateTransform) + { + rotateTransform.Angle = rect.Rotation; + } + else if (rect.Rotation != 0d) + { + rotateTransform = new RotateTransform { Angle = rect.Rotation }; + element.RenderTransform = rotateTransform; + element.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); + } + } + + private static void ArrangeElement(Control element, Rect rect) + { + if (element.UseLayoutRounding) + { + rect = new Rect(Math.Round(rect.X), Math.Round(rect.Y), Math.Round(rect.Width), Math.Round(rect.Height)); + } + + element.Arrange(rect); + } + + internal static Size GetDesiredSize(Control element) + { + var width = 0d; + var height = 0d; + + if (element.DesiredSize.Width >= 0d && + element.DesiredSize.Width < double.PositiveInfinity) + { + width = element.DesiredSize.Width; + } + + if (element.DesiredSize.Height >= 0d && + element.DesiredSize.Height < double.PositiveInfinity) + { + height = element.DesiredSize.Height; + } + + return new Size(width, height); + } + + private void OnViewportChanged(object sender, ViewportChangedEventArgs e) + { + OnViewportChanged(e); + } + + private void OnParentMapPropertyChanged(MapBase parentMap) + { + if (ParentMap != null && ParentMap != this) + { + ParentMap.ViewportChanged -= OnViewportChanged; + } + + ParentMap = parentMap; + + if (ParentMap != null && ParentMap != this) + { + ParentMap.ViewportChanged += OnViewportChanged; + + OnViewportChanged(new ViewportChangedEventArgs()); + } + } + } +} diff --git a/MapControl/Avalonia/MapTileLayer.cs b/MapControl/Avalonia/MapTileLayer.cs new file mode 100644 index 00000000..dbd9f28e --- /dev/null +++ b/MapControl/Avalonia/MapTileLayer.cs @@ -0,0 +1,231 @@ +// XAML Map Control - https://github.com/ClemensFischer/XAML-Map-Control +// Copyright © 2024 Clemens Fischer +// Licensed under the Microsoft Public License (Ms-PL) + +using Avalonia.Media; +using System; +using System.Threading.Tasks; + +namespace MapControl +{ + /// + /// Displays a standard Web Mercator map tile grid, e.g. an OpenStreetMap tile grid. + /// + public class MapTileLayer : MapTileLayerBase + { + private const int TileSize = 256; + + private static readonly Point MapTopLeft = new( + -180d * MapProjection.Wgs84MeterPerDegree, 180d * MapProjection.Wgs84MeterPerDegree); + + /// + /// A default MapTileLayer using OpenStreetMap data. + /// + public static MapTileLayer OpenStreetMapTileLayer => new() + { + TileSource = new TileSource { UriTemplate = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" }, + SourceName = "OpenStreetMap", + Description = "© [OpenStreetMap Contributors](http://www.openstreetmap.org/copyright)" + }; + + public static readonly StyledProperty MinZoomLevelProperty + = AvaloniaProperty.Register(nameof(MinZoomLevel)); + + public static readonly StyledProperty MaxZoomLevelProperty + = AvaloniaProperty.Register(nameof(MaxZoomLevel), 19); + + public static readonly StyledProperty ZoomLevelOffsetProperty + = AvaloniaProperty.Register(nameof(ZoomLevelOffset)); + + public TileMatrix TileMatrix { get; private set; } + + public TileCollection Tiles { get; private set; } = []; + + /// + /// Minimum zoom level supported by the MapTileLayer. Default value is 0. + /// + public int MinZoomLevel + { + get => GetValue(MinZoomLevelProperty); + set => SetValue(MinZoomLevelProperty, value); + } + + /// + /// Maximum zoom level supported by the MapTileLayer. Default value is 19. + /// + public int MaxZoomLevel + { + get => GetValue(MaxZoomLevelProperty); + set => SetValue(MaxZoomLevelProperty, value); + } + + /// + /// Optional offset between the map zoom level and the topmost tile zoom level. + /// Default value is 0. + /// + public double ZoomLevelOffset + { + get => GetValue(ZoomLevelOffsetProperty); + set => SetValue(ZoomLevelOffsetProperty, value); + } + + protected override Size MeasureOverride(Size availableSize) + { + availableSize = new Size(double.PositiveInfinity, double.PositiveInfinity); + + foreach (var tile in Tiles) + { + tile.Image.Measure(availableSize); + } + + return new Size(); + } + + protected override Size ArrangeOverride(Size finalSize) + { + if (TileMatrix != null) + { + foreach (var tile in Tiles) + { + // Arrange tiles relative to XMin/YMin. + // + var tileSize = TileSize << (TileMatrix.ZoomLevel - tile.ZoomLevel); + var x = tileSize * tile.X - TileSize * TileMatrix.XMin; + var y = tileSize * tile.Y - TileSize * TileMatrix.YMin; + + tile.Image.Width = tileSize; + tile.Image.Height = tileSize; + tile.Image.Arrange(new Rect(x, y, tileSize, tileSize)); + } + } + + return finalSize; + } + + protected override Task UpdateTileLayer(bool tileSourceChanged) + { + var updateTiles = false; + + if (ParentMap == null || ParentMap.MapProjection.Type != MapProjectionType.WebMercator) + { + updateTiles = TileMatrix != null; + TileMatrix = null; + } + else + { + if (tileSourceChanged) + { + Tiles = []; // clear all + updateTiles = true; + } + + if (SetTileMatrix()) + { + updateTiles = true; + } + + SetRenderTransform(); + } + + if (updateTiles) + { + UpdateTiles(); + + return LoadTiles(Tiles, SourceName); + } + + return Task.CompletedTask; + } + + protected override void SetRenderTransform() + { + if (TileMatrix != null) + { + // Tile matrix origin in pixels. + // + var tileMatrixOrigin = new Point(TileSize * TileMatrix.XMin, TileSize * TileMatrix.YMin); + + var tileMatrixScale = ViewTransform.ZoomLevelToScale(TileMatrix.ZoomLevel); + + ((MatrixTransform)RenderTransform).Matrix = + ParentMap.ViewTransform.GetTileLayerTransform(tileMatrixScale, MapTopLeft, tileMatrixOrigin); + } + } + + private bool SetTileMatrix() + { + // Add 0.001 to avoid rounding issues. + // + var tileMatrixZoomLevel = (int)Math.Floor(ParentMap.ZoomLevel - ZoomLevelOffset + 0.001); + + var tileMatrixScale = ViewTransform.ZoomLevelToScale(tileMatrixZoomLevel); + + // Bounds in tile pixels from view size. + // + var bounds = ParentMap.ViewTransform.GetTileMatrixBounds(tileMatrixScale, MapTopLeft, ParentMap.Bounds.Size); + + // Tile X and Y bounds. + // + var xMin = (int)Math.Floor(bounds.X / TileSize); + var yMin = (int)Math.Floor(bounds.Y / TileSize); + var xMax = (int)Math.Floor((bounds.X + bounds.Width) / TileSize); + var yMax = (int)Math.Floor((bounds.Y + bounds.Height) / TileSize); + + if (TileMatrix != null && + TileMatrix.ZoomLevel == tileMatrixZoomLevel && + TileMatrix.XMin == xMin && TileMatrix.YMin == yMin && + TileMatrix.XMax == xMax && TileMatrix.YMax == yMax) + { + return false; + } + + TileMatrix = new TileMatrix(tileMatrixZoomLevel, xMin, yMin, xMax, yMax); + + return true; + } + + private void UpdateTiles() + { + var tiles = new TileCollection(); + + if (TileSource != null && TileMatrix != null) + { + var maxZoomLevel = Math.Min(TileMatrix.ZoomLevel, MaxZoomLevel); + + if (maxZoomLevel >= MinZoomLevel) + { + var minZoomLevel = IsBaseMapLayer + ? Math.Max(TileMatrix.ZoomLevel - MaxBackgroundLevels, MinZoomLevel) + : maxZoomLevel; + + for (var z = minZoomLevel; z <= maxZoomLevel; z++) + { + var numTiles = 1 << z; + var tileSize = 1 << (TileMatrix.ZoomLevel - z); + var x1 = (int)Math.Floor((double)TileMatrix.XMin / tileSize); // may be negative + var x2 = TileMatrix.XMax / tileSize; // may be greater than numTiles-1 + var y1 = Math.Max(TileMatrix.YMin / tileSize, 0); + var y2 = Math.Min(TileMatrix.YMax / tileSize, numTiles - 1); + + for (var y = y1; y <= y2; y++) + { + for (var x = x1; x <= x2; x++) + { + tiles.Add(Tiles.GetTile(z, x, y, numTiles)); + } + } + } + } + } + + Tiles = tiles; + + Children.Clear(); + + foreach (var tile in tiles) + { + Children.Add(tile.Image); + } + } + } +} diff --git a/MapControl/Avalonia/MapTileLayerBase.cs b/MapControl/Avalonia/MapTileLayerBase.cs new file mode 100644 index 00000000..a10b6ce5 --- /dev/null +++ b/MapControl/Avalonia/MapTileLayerBase.cs @@ -0,0 +1,214 @@ +// XAML Map Control - https://github.com/ClemensFischer/XAML-Map-Control +// Copyright © 2024 Clemens Fischer +// Licensed under the Microsoft Public License (Ms-PL) + +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace MapControl +{ + public abstract class MapTileLayerBase : Panel + { + public static readonly StyledProperty TileSourceProperty + = AvaloniaProperty.Register(nameof(TileSource)); + + public static readonly StyledProperty SourceNameProperty + = AvaloniaProperty.Register(nameof(SourceName)); + + public static readonly StyledProperty DescriptionProperty + = AvaloniaProperty.Register(nameof(Description)); + + public static readonly StyledProperty MaxBackgroundLevelsProperty + = AvaloniaProperty.Register(nameof(MaxBackgroundLevels), 5); + + public static readonly StyledProperty UpdateIntervalProperty + = AvaloniaProperty.Register(nameof(AvaloniaProperty), TimeSpan.FromSeconds(0.2)); + + public static readonly StyledProperty UpdateWhileViewportChangingProperty + = AvaloniaProperty.Register(nameof(UpdateWhileViewportChanging)); + + public static readonly StyledProperty MapBackgroundProperty + = AvaloniaProperty.Register(nameof(MapBackground)); + + public static readonly StyledProperty MapForegroundProperty + = AvaloniaProperty.Register(nameof(MapForeground)); + + public static readonly DirectProperty LoadingProgressProperty + = AvaloniaProperty.RegisterDirect(nameof(LoadingProgress), layer => layer.loadingProgressValue); + + private readonly DispatcherTimer updateTimer; + private readonly Progress loadingProgress; + private double loadingProgressValue; + private ITileImageLoader tileImageLoader; + + static MapTileLayerBase() + { + MapPanel.ParentMapProperty.Changed.AddClassHandler( + (layer, args) => layer.OnParentMapPropertyChanged(args.NewValue.Value)); + + TileSourceProperty.Changed.AddClassHandler( + async (layer, args) => await layer.Update(true)); + + UpdateIntervalProperty.Changed.AddClassHandler( + (layer, args) => layer.updateTimer.Interval = args.NewValue.Value); + } + + protected MapTileLayerBase() + { + RenderTransform = new MatrixTransform(); + RenderTransformOrigin = new RelativePoint(); + + loadingProgress = new Progress(p => SetAndRaise(LoadingProgressProperty, ref loadingProgressValue, p)); + + updateTimer = this.CreateTimer(UpdateInterval); + updateTimer.Tick += async (s, e) => await Update(false); + } + + public MapBase ParentMap { get; private set; } + + public ITileImageLoader TileImageLoader + { + get => tileImageLoader ??= new TileImageLoader(); + set => tileImageLoader = value; + } + + /// + /// Provides map tile URIs or images. + /// + public TileSource TileSource + { + get => GetValue(TileSourceProperty); + set => SetValue(TileSourceProperty, value); + } + + /// + /// Name of the TileSource. Used as component of a tile cache key. + /// + public string SourceName + { + get => GetValue(SourceNameProperty); + set => SetValue(SourceNameProperty, value); + } + + /// + /// Description of the layer. Used to display copyright information on top of the map. + /// + public string Description + { + get => GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + /// + /// Maximum number of background tile levels. Default value is 5. + /// Only effective in a MapTileLayer or WmtsTileLayer that is the MapLayer of its ParentMap. + /// + public int MaxBackgroundLevels + { + get => GetValue(MaxBackgroundLevelsProperty); + set => SetValue(MaxBackgroundLevelsProperty, value); + } + + /// + /// Minimum time interval between tile updates. + /// + public TimeSpan UpdateInterval + { + get => GetValue(UpdateIntervalProperty); + set => SetValue(UpdateIntervalProperty, value); + } + + /// + /// Controls if tiles are updated while the viewport is still changing. + /// + public bool UpdateWhileViewportChanging + { + get => GetValue(UpdateWhileViewportChangingProperty); + set => SetValue(UpdateWhileViewportChangingProperty, value); + } + + /// + /// Optional background brush. Sets MapBase.Background if not null and this layer is the base map layer. + /// + public IBrush MapBackground + { + get => GetValue(MapBackgroundProperty); + set => SetValue(MapBackgroundProperty, value); + } + + /// + /// Optional foreground brush. Sets MapBase.Foreground if not null and this layer is the base map layer. + /// + public IBrush MapForeground + { + get => GetValue(MapForegroundProperty); + set => SetValue(MapForegroundProperty, value); + } + + /// + /// Gets the progress of the TileImageLoader as a double value between 0 and 1. + /// + public double LoadingProgress => loadingProgressValue; + + protected bool IsBaseMapLayer + { + get + { + var parentMap = MapPanel.GetParentMap(this); + + return parentMap != null && parentMap.Children.Count > 0 && parentMap.Children[0] == this; + } + } + + protected abstract void SetRenderTransform(); + + protected abstract Task UpdateTileLayer(bool tileSourceChanged); + + protected Task LoadTiles(IEnumerable tiles, string cacheName) + { + return TileImageLoader.LoadTilesAsync(tiles, TileSource, cacheName, loadingProgress); + } + + private Task Update(bool tileSourceChanged) + { + updateTimer.Stop(); + + return UpdateTileLayer(tileSourceChanged); + } + + private async void OnViewportChanged(object sender, ViewportChangedEventArgs e) + { + if (e.TransformCenterChanged || e.ProjectionChanged || Children.Count == 0) + { + await Update(false); // update immediately + } + else + { + SetRenderTransform(); + + updateTimer.Run(!UpdateWhileViewportChanging); + } + } + + private void OnParentMapPropertyChanged(MapBase parentMap) + { + if (ParentMap != null) + { + ParentMap.ViewportChanged -= OnViewportChanged; + } + + ParentMap = parentMap; + + if (ParentMap != null) + { + ParentMap.ViewportChanged += OnViewportChanged; + } + + updateTimer.Run(); + } + } +} diff --git a/MapControl/Avalonia/Tile.Avalonia.cs b/MapControl/Avalonia/Tile.Avalonia.cs new file mode 100644 index 00000000..46245212 --- /dev/null +++ b/MapControl/Avalonia/Tile.Avalonia.cs @@ -0,0 +1,36 @@ +// XAML Map Control - https://github.com/ClemensFischer/XAML-Map-Control +// Copyright © 2024 Clemens Fischer +// Licensed under the Microsoft Public License (Ms-PL) + +using Avalonia.Animation; +using Avalonia.Styling; +using System; + +namespace MapControl +{ + public partial class Tile + { + private void AnimateImageOpacity() + { + var animation = new Animation + { + Duration = MapBase.ImageFadeDuration, + Children = + { + new KeyFrame + { + KeyTime = TimeSpan.Zero, + Setters = { new Setter(Visual.OpacityProperty, 0d) } + }, + new KeyFrame + { + KeyTime = MapBase.ImageFadeDuration, + Setters = { new Setter(Visual.OpacityProperty, 1d) } + } + } + }; + + _ = animation.RunAsync(Image); + } + } +} diff --git a/MapControl/Avalonia/TileImageLoader.Avalonia.cs b/MapControl/Avalonia/TileImageLoader.Avalonia.cs new file mode 100644 index 00000000..b8f2a410 --- /dev/null +++ b/MapControl/Avalonia/TileImageLoader.Avalonia.cs @@ -0,0 +1,29 @@ +// XAML Map Control - https://github.com/ClemensFischer/XAML-Map-Control +// Copyright © 2024 Clemens Fischer +// Licensed under the Microsoft Public License (Ms-PL) + +using Avalonia.Media; +using Avalonia.Threading; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace MapControl +{ + public partial class TileImageLoader + { + /// + /// Default folder where the Cache instance may save data, i.e. "C:\ProgramData\MapControl\TileCache". + /// + public static string DefaultCacheFolder => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "MapControl", "TileCache"); + + + private static async Task LoadTileAsync(Tile tile, Func> loadImageFunc) + { + var image = await loadImageFunc().ConfigureAwait(false); + + await Dispatcher.UIThread.InvokeAsync(() => tile.SetImageSource(image)); + } + } +} diff --git a/MapControl/Avalonia/Timer.Avalonia.cs b/MapControl/Avalonia/Timer.Avalonia.cs new file mode 100644 index 00000000..dca713c8 --- /dev/null +++ b/MapControl/Avalonia/Timer.Avalonia.cs @@ -0,0 +1,35 @@ +// XAML Map Control - https://github.com/ClemensFischer/XAML-Map-Control +// Copyright © 2024 Clemens Fischer +// Licensed under the Microsoft Public License (Ms-PL) + +using Avalonia.Threading; +using System; + +namespace MapControl +{ + internal static class Timer + { + public static DispatcherTimer CreateTimer(this AvaloniaObject obj, TimeSpan interval) + { + var timer = new DispatcherTimer + { + Interval = interval + }; + + return timer; + } + + public static void Run(this DispatcherTimer timer, bool restart = false) + { + if (restart) + { + timer.Stop(); + } + + if (!timer.IsEnabled) + { + timer.Start(); + } + } + } +} diff --git a/MapControl/Avalonia/ViewTransform.cs b/MapControl/Avalonia/ViewTransform.cs new file mode 100644 index 00000000..a51dff38 --- /dev/null +++ b/MapControl/Avalonia/ViewTransform.cs @@ -0,0 +1,115 @@ +// XAML Map Control - https://github.com/ClemensFischer/XAML-Map-Control +// Copyright © 2024 Clemens Fischer +// Licensed under the Microsoft Public License (Ms-PL) + +using System; + +namespace MapControl +{ + /// + /// Defines the transformation between projected map coordinates in meters + /// and view coordinates in pixels. + /// + public class ViewTransform + { + public static double ZoomLevelToScale(double zoomLevel) + { + return 256d * Math.Pow(2d, zoomLevel) / (360d * MapProjection.Wgs84MeterPerDegree); + } + + public static double ScaleToZoomLevel(double scale) + { + return Math.Log(scale * 360d * MapProjection.Wgs84MeterPerDegree / 256d, 2d); + } + + /// + /// Gets the scaling factor from projected map coordinates to view coordinates, + /// as pixels per meter. + /// + public double Scale { get; private set; } + + /// + /// Gets the rotation angle of the transform matrix. + /// + public double Rotation { get; private set; } + + /// + /// Gets the transform matrix from projected map coordinates to view coordinates. + /// + public Matrix MapToViewMatrix { get; private set; } + + /// + /// Gets the transform matrix from view coordinates to projected map coordinates. + /// + public Matrix ViewToMapMatrix { get; private set; } + + /// + /// Transforms a Point from projected map coordinates to view coordinates. + /// + public Point MapToView(Point point) + { + return MapToViewMatrix.Transform(point); + } + + /// + /// Transforms a Point from view coordinates to projected map coordinates. + /// + public Point ViewToMap(Point point) + { + return ViewToMapMatrix.Transform(point); + } + + public void SetTransform(Point mapCenter, Point viewCenter, double scale, double rotation) + { + Scale = scale; + Rotation = ((rotation % 360d) + 360d) % 360d; + + MapToViewMatrix = new Matrix(Scale, 0d, 0d, -Scale, -Scale * mapCenter.X, Scale * mapCenter.Y) + .Append(Matrix.CreateRotation(Rotation * Math.PI / 180d)) + .Append(Matrix.CreateTranslation(viewCenter.X, viewCenter.Y)); + + ViewToMapMatrix = MapToViewMatrix.Invert(); + } + + public Matrix GetTileLayerTransform(double tileMatrixScale, Point tileMatrixTopLeft, Point tileMatrixOrigin) + { + // Tile matrix origin in map coordinates. + // + var mapOrigin = new Point( + tileMatrixTopLeft.X + tileMatrixOrigin.X / tileMatrixScale, + tileMatrixTopLeft.Y - tileMatrixOrigin.Y / tileMatrixScale); + + // Tile matrix origin in view coordinates. + // + var viewOrigin = MapToView(mapOrigin); + + var transformScale = Scale / tileMatrixScale; + + return new Matrix(transformScale, 0d, 0d, transformScale, 0d, 0d) + .Append(Matrix.CreateRotation(Rotation * Math.PI / 180d)) + .Append(Matrix.CreateTranslation(viewOrigin.X, viewOrigin.Y)); + } + + public Rect GetTileMatrixBounds(double tileMatrixScale, Point tileMatrixTopLeft, Size viewSize) + { + // View origin in map coordinates. + // + var origin = ViewToMap(new Point()); + + var transformScale = tileMatrixScale / Scale; + + var transform = new Matrix(transformScale, 0d, 0d, transformScale, 0d, 0d) + .Append(Matrix.CreateRotation(-Rotation * Math.PI / 180d)); + + // Translate origin to tile matrix origin in pixels. + // + transform = transform.Append(Matrix.CreateTranslation( + tileMatrixScale * (origin.X - tileMatrixTopLeft.X), + tileMatrixScale * (tileMatrixTopLeft.Y - origin.Y))); + + // Transform view bounds to tile pixel bounds. + // + return new Rect(0d, 0d, viewSize.Width, viewSize.Height).TransformToAABB(transform); + } + } +}