File scoped namespaces

This commit is contained in:
ClemensFischer 2026-04-13 17:14:49 +02:00
parent c14377f976
commit 65aba44af6
152 changed files with 11962 additions and 12115 deletions

View file

@ -7,200 +7,199 @@ using System.IO;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace MapControl.Caching namespace MapControl.Caching;
public class FileDbCacheOptions : IOptions<FileDbCacheOptions>
{ {
public class FileDbCacheOptions : IOptions<FileDbCacheOptions> public FileDbCacheOptions Value => this;
public string Path { get; set; }
public TimeSpan ExpirationScanFrequency { get; set; } = TimeSpan.FromHours(1);
}
/// <summary>
/// IDistributedCache implementation based on FileDb, https://github.com/eztools-software/FileDb.
/// </summary>
public sealed class FileDbCache : IDistributedCache, IDisposable
{
private const string KeyField = "Key";
private const string ValueField = "Value";
private const string ExpiresField = "Expires";
private readonly FileDb fileDb = new FileDb { AutoFlush = true };
private readonly Timer timer;
private readonly ILogger logger;
public FileDbCache(string path, ILoggerFactory loggerFactory = null)
: this(new FileDbCacheOptions { Path = path }, loggerFactory)
{ {
public FileDbCacheOptions Value => this;
public string Path { get; set; }
public TimeSpan ExpirationScanFrequency { get; set; } = TimeSpan.FromHours(1);
} }
/// <summary> public FileDbCache(IOptions<FileDbCacheOptions> optionsAccessor, ILoggerFactory loggerFactory = null)
/// IDistributedCache implementation based on FileDb, https://github.com/eztools-software/FileDb. : this(optionsAccessor.Value, loggerFactory)
/// </summary>
public sealed class FileDbCache : IDistributedCache, IDisposable
{ {
private const string KeyField = "Key"; }
private const string ValueField = "Value";
private const string ExpiresField = "Expires";
private readonly FileDb fileDb = new FileDb { AutoFlush = true }; public FileDbCache(FileDbCacheOptions options, ILoggerFactory loggerFactory = null)
private readonly Timer timer; {
private readonly ILogger logger; var path = options.Path;
public FileDbCache(string path, ILoggerFactory loggerFactory = null) if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(Path.GetExtension(path)))
: this(new FileDbCacheOptions { Path = path }, loggerFactory)
{ {
path = Path.Combine(path ?? "", "TileCache.fdb");
} }
public FileDbCache(IOptions<FileDbCacheOptions> optionsAccessor, ILoggerFactory loggerFactory = null) logger = loggerFactory?.CreateLogger<FileDbCache>();
: this(optionsAccessor.Value, loggerFactory)
try
{ {
fileDb.Open(path);
logger?.LogInformation("Opened database {path}", path);
} }
catch
public FileDbCache(FileDbCacheOptions options, ILoggerFactory loggerFactory = null)
{ {
var path = options.Path; if (File.Exists(path))
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(Path.GetExtension(path)))
{ {
path = Path.Combine(path ?? "", "TileCache.fdb"); File.Delete(path);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(path));
} }
logger = loggerFactory?.CreateLogger<FileDbCache>(); fileDb.Create(path, new Field[]
{
new Field(KeyField, DataTypeEnum.String) { IsPrimaryKey = true },
new Field(ValueField, DataTypeEnum.Byte) { IsArray = true },
new Field(ExpiresField, DataTypeEnum.DateTime)
});
logger?.LogInformation("Created database {path}", path);
}
if (options.ExpirationScanFrequency > TimeSpan.Zero)
{
timer = new Timer(_ => DeleteExpiredItems(), null, TimeSpan.Zero, options.ExpirationScanFrequency);
}
}
public void Dispose()
{
timer?.Dispose();
fileDb.Dispose();
}
public byte[] Get(string key)
{
byte[] value = null;
if (!string.IsNullOrEmpty(key))
{
try
{
var record = fileDb.GetRecordByKey(key, new string[] { ValueField, ExpiresField }, false);
if (record != null && (DateTime)record[1] > DateTime.UtcNow)
{
value = (byte[])record[0];
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Get({key})", key);
}
}
return value;
}
public Task<byte[]> GetAsync(string key, CancellationToken token = default)
{
return Task.FromResult(Get(key));
}
public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
{
if (!string.IsNullOrEmpty(key) && value != null && options != null)
{
var expiration = options.AbsoluteExpiration.HasValue
? options.AbsoluteExpiration.Value.UtcDateTime
: DateTime.UtcNow.Add(options.AbsoluteExpirationRelativeToNow ?? options.SlidingExpiration ?? TimeSpan.FromDays(1));
var fieldValues = new FieldValues(3)
{
{ ValueField, value },
{ ExpiresField, expiration }
};
try try
{ {
fileDb.Open(path); if (fileDb.GetRecordByKey(key, new string[0], false) != null)
logger?.LogInformation("Opened database {path}", path);
}
catch
{
if (File.Exists(path))
{ {
File.Delete(path); fileDb.UpdateRecordByKey(key, fieldValues);
} }
else else
{ {
Directory.CreateDirectory(Path.GetDirectoryName(path)); fieldValues.Add(KeyField, key);
} fileDb.AddRecord(fieldValues);
fileDb.Create(path, new Field[]
{
new Field(KeyField, DataTypeEnum.String) { IsPrimaryKey = true },
new Field(ValueField, DataTypeEnum.Byte) { IsArray = true },
new Field(ExpiresField, DataTypeEnum.DateTime)
});
logger?.LogInformation("Created database {path}", path);
}
if (options.ExpirationScanFrequency > TimeSpan.Zero)
{
timer = new Timer(_ => DeleteExpiredItems(), null, TimeSpan.Zero, options.ExpirationScanFrequency);
}
}
public void Dispose()
{
timer?.Dispose();
fileDb.Dispose();
}
public byte[] Get(string key)
{
byte[] value = null;
if (!string.IsNullOrEmpty(key))
{
try
{
var record = fileDb.GetRecordByKey(key, new string[] { ValueField, ExpiresField }, false);
if (record != null && (DateTime)record[1] > DateTime.UtcNow)
{
value = (byte[])record[0];
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Get({key})", key);
} }
} }
catch (Exception ex)
return value;
}
public Task<byte[]> GetAsync(string key, CancellationToken token = default)
{
return Task.FromResult(Get(key));
}
public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
{
if (!string.IsNullOrEmpty(key) && value != null && options != null)
{ {
var expiration = options.AbsoluteExpiration.HasValue logger?.LogError(ex, "Set({key})", key);
? options.AbsoluteExpiration.Value.UtcDateTime
: DateTime.UtcNow.Add(options.AbsoluteExpirationRelativeToNow ?? options.SlidingExpiration ?? TimeSpan.FromDays(1));
var fieldValues = new FieldValues(3)
{
{ ValueField, value },
{ ExpiresField, expiration }
};
try
{
if (fileDb.GetRecordByKey(key, new string[0], false) != null)
{
fileDb.UpdateRecordByKey(key, fieldValues);
}
else
{
fieldValues.Add(KeyField, key);
fileDb.AddRecord(fieldValues);
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Set({key})", key);
}
}
}
public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default)
{
Set(key, value, options);
return Task.CompletedTask;
}
public void Refresh(string key)
{
}
public Task RefreshAsync(string key, CancellationToken token = default)
{
return Task.CompletedTask;
}
public void Remove(string key)
{
if (!string.IsNullOrEmpty(key))
{
try
{
fileDb.DeleteRecordByKey(key);
}
catch (Exception ex)
{
logger?.LogError(ex, "Remove({key})", key);
}
}
}
public Task RemoveAsync(string key, CancellationToken token = default)
{
Remove(key);
return Task.CompletedTask;
}
public void DeleteExpiredItems()
{
var deletedItemsCount = fileDb.DeleteRecords(new FilterExpression(ExpiresField, DateTime.UtcNow, ComparisonOperatorEnum.LessThanOrEqual));
if (deletedItemsCount > 0)
{
fileDb.Clean();
logger?.LogInformation("Deleted {count} expired items", deletedItemsCount);
} }
} }
} }
public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default)
{
Set(key, value, options);
return Task.CompletedTask;
}
public void Refresh(string key)
{
}
public Task RefreshAsync(string key, CancellationToken token = default)
{
return Task.CompletedTask;
}
public void Remove(string key)
{
if (!string.IsNullOrEmpty(key))
{
try
{
fileDb.DeleteRecordByKey(key);
}
catch (Exception ex)
{
logger?.LogError(ex, "Remove({key})", key);
}
}
}
public Task RemoveAsync(string key, CancellationToken token = default)
{
Remove(key);
return Task.CompletedTask;
}
public void DeleteExpiredItems()
{
var deletedItemsCount = fileDb.DeleteRecords(new FilterExpression(ExpiresField, DateTime.UtcNow, ComparisonOperatorEnum.LessThanOrEqual));
if (deletedItemsCount > 0)
{
fileDb.Clean();
logger?.LogInformation("Deleted {count} expired items", deletedItemsCount);
}
}
} }

View file

@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework> <TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>14.0</LangVersion>
<RootNamespace>MapControl.Caching</RootNamespace> <RootNamespace>MapControl.Caching</RootNamespace>
<AssemblyTitle>XAML Map Control FileDbCache Library</AssemblyTitle> <AssemblyTitle>XAML Map Control FileDbCache Library</AssemblyTitle>
<GeneratePackageOnBuild>$(GeneratePackage)</GeneratePackageOnBuild> <GeneratePackageOnBuild>$(GeneratePackage)</GeneratePackageOnBuild>

View file

@ -7,250 +7,249 @@ using System.IO;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace MapControl.Caching namespace MapControl.Caching;
public class SQLiteCacheOptions : IOptions<SQLiteCacheOptions>
{ {
public class SQLiteCacheOptions : IOptions<SQLiteCacheOptions> public SQLiteCacheOptions Value => this;
public string Path { get; set; }
public TimeSpan ExpirationScanFrequency { get; set; } = TimeSpan.FromHours(1);
}
/// <summary>
/// IDistributedCache implementation based on System.Data.SQLite, https://system.data.sqlite.org/.
/// </summary>
public sealed class SQLiteCache : IDistributedCache, IDisposable
{
private readonly SQLiteConnection connection;
private readonly Timer timer;
private readonly ILogger logger;
public SQLiteCache(string path, ILoggerFactory loggerFactory = null)
: this(new SQLiteCacheOptions { Path = path }, loggerFactory)
{ {
public SQLiteCacheOptions Value => this;
public string Path { get; set; }
public TimeSpan ExpirationScanFrequency { get; set; } = TimeSpan.FromHours(1);
} }
/// <summary> public SQLiteCache(IOptions<SQLiteCacheOptions> optionsAccessor, ILoggerFactory loggerFactory = null)
/// IDistributedCache implementation based on System.Data.SQLite, https://system.data.sqlite.org/. : this(optionsAccessor.Value, loggerFactory)
/// </summary>
public sealed class SQLiteCache : IDistributedCache, IDisposable
{ {
private readonly SQLiteConnection connection; }
private readonly Timer timer;
private readonly ILogger logger;
public SQLiteCache(string path, ILoggerFactory loggerFactory = null) public SQLiteCache(SQLiteCacheOptions options, ILoggerFactory loggerFactory = null)
: this(new SQLiteCacheOptions { Path = path }, loggerFactory) {
var path = options.Path;
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(Path.GetExtension(path)))
{ {
path = Path.Combine(path ?? "", "TileCache.sqlite");
} }
public SQLiteCache(IOptions<SQLiteCacheOptions> optionsAccessor, ILoggerFactory loggerFactory = null) connection = new SQLiteConnection("Data Source=" + path);
: this(optionsAccessor.Value, loggerFactory) connection.Open();
using (var command = new SQLiteCommand("pragma journal_mode=wal", connection))
{ {
command.ExecuteNonQuery();
} }
public SQLiteCache(SQLiteCacheOptions options, ILoggerFactory loggerFactory = null) using (var command = new SQLiteCommand("create table if not exists items (key text primary key, expiration integer, buffer blob)", connection))
{ {
var path = options.Path; command.ExecuteNonQuery();
}
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(Path.GetExtension(path))) logger = loggerFactory?.CreateLogger<SQLiteCache>();
logger?.LogInformation("Opened database {path}", path);
if (options.ExpirationScanFrequency > TimeSpan.Zero)
{
timer = new Timer(_ => DeleteExpiredItems(), null, TimeSpan.Zero, options.ExpirationScanFrequency);
}
}
public void Dispose()
{
timer?.Dispose();
connection.Dispose();
}
public byte[] Get(string key)
{
byte[] value = null;
if (!string.IsNullOrEmpty(key))
{
try
{ {
path = Path.Combine(path ?? "", "TileCache.sqlite"); using (var command = GetItemCommand(key))
{
value = (byte[])command.ExecuteScalar();
}
} }
catch (Exception ex)
connection = new SQLiteConnection("Data Source=" + path);
connection.Open();
using (var command = new SQLiteCommand("pragma journal_mode=wal", connection))
{ {
command.ExecuteNonQuery(); logger?.LogError(ex, "Get({key})", key);
}
using (var command = new SQLiteCommand("create table if not exists items (key text primary key, expiration integer, buffer blob)", connection))
{
command.ExecuteNonQuery();
}
logger = loggerFactory?.CreateLogger<SQLiteCache>();
logger?.LogInformation("Opened database {path}", path);
if (options.ExpirationScanFrequency > TimeSpan.Zero)
{
timer = new Timer(_ => DeleteExpiredItems(), null, TimeSpan.Zero, options.ExpirationScanFrequency);
} }
} }
public void Dispose() return value;
{ }
timer?.Dispose();
connection.Dispose();
}
public byte[] Get(string key) public async Task<byte[]> GetAsync(string key, CancellationToken token = default)
{ {
byte[] value = null; byte[] value = null;
if (!string.IsNullOrEmpty(key)) if (!string.IsNullOrEmpty(key))
{
try
{ {
try using (var command = GetItemCommand(key))
{ {
using (var command = GetItemCommand(key)) value = (byte[])await command.ExecuteScalarAsync();
{
value = (byte[])command.ExecuteScalar();
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Get({key})", key);
} }
} }
catch (Exception ex)
return value;
}
public async Task<byte[]> GetAsync(string key, CancellationToken token = default)
{
byte[] value = null;
if (!string.IsNullOrEmpty(key))
{ {
try logger?.LogError(ex, "GetAsync({key})", key);
{
using (var command = GetItemCommand(key))
{
value = (byte[])await command.ExecuteScalarAsync();
}
}
catch (Exception ex)
{
logger?.LogError(ex, "GetAsync({key})", key);
}
}
return value;
}
public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
{
if (!string.IsNullOrEmpty(key) && value != null && options != null)
{
try
{
using (var command = SetItemCommand(key, value, options))
{
command.ExecuteNonQuery();
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Set({key})", key);
}
} }
} }
public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) return value;
}
public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
{
if (!string.IsNullOrEmpty(key) && value != null && options != null)
{ {
if (!string.IsNullOrEmpty(key) && value != null && options != null) try
{ {
try using (var command = SetItemCommand(key, value, options))
{
using (var command = SetItemCommand(key, value, options))
{
await command.ExecuteNonQueryAsync(token);
}
}
catch (Exception ex)
{
logger?.LogError(ex, "SetAsync({key})", key);
}
}
}
public void Refresh(string key)
{
}
public Task RefreshAsync(string key, CancellationToken token = default)
{
return Task.CompletedTask;
}
public void Remove(string key)
{
if (!string.IsNullOrEmpty(key))
{
try
{
using (var command = DeleteItemCommand(key))
{
command.ExecuteNonQuery();
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Remove({key})", key);
}
}
}
public async Task RemoveAsync(string key, CancellationToken token = default)
{
if (!string.IsNullOrEmpty(key))
{
try
{
using (var command = DeleteItemCommand(key))
{
await command.ExecuteNonQueryAsync();
}
}
catch (Exception ex)
{
logger?.LogError(ex, "RemoveAsync({key})", key);
}
}
}
public void DeleteExpiredItems()
{
long deletedItemsCount;
using (var command = DeleteExpiredItemsCommand())
{
deletedItemsCount = (long)command.ExecuteScalar();
}
if (deletedItemsCount > 0)
{
using (var command = new SQLiteCommand("vacuum", connection))
{ {
command.ExecuteNonQuery(); command.ExecuteNonQuery();
} }
}
logger?.LogInformation("Deleted {count} expired items", deletedItemsCount); catch (Exception ex)
{
logger?.LogError(ex, "Set({key})", key);
} }
} }
}
private SQLiteCommand GetItemCommand(string key) public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default)
{
if (!string.IsNullOrEmpty(key) && value != null && options != null)
{ {
var command = new SQLiteCommand("select buffer from items where key = @key and expiration > @exp", connection); try
command.Parameters.AddWithValue("@key", key); {
command.Parameters.AddWithValue("@exp", DateTimeOffset.UtcNow.Ticks); using (var command = SetItemCommand(key, value, options))
return command; {
} await command.ExecuteNonQueryAsync(token);
}
private SQLiteCommand SetItemCommand(string key, byte[] buffer, DistributedCacheEntryOptions options) }
{ catch (Exception ex)
var expiration = options.AbsoluteExpiration ?? {
DateTimeOffset.UtcNow.Add(options.AbsoluteExpirationRelativeToNow ?? options.SlidingExpiration ?? TimeSpan.FromDays(1)); logger?.LogError(ex, "SetAsync({key})", key);
}
var command = new SQLiteCommand("insert or replace into items (key, expiration, buffer) values (@key, @exp, @buf)", connection);
command.Parameters.AddWithValue("@key", key);
command.Parameters.AddWithValue("@exp", expiration.UtcTicks);
command.Parameters.AddWithValue("@buf", buffer);
return command;
}
private SQLiteCommand DeleteItemCommand(string key)
{
var command = new SQLiteCommand("delete from items where key = @key", connection);
command.Parameters.AddWithValue("@key", key);
return command;
}
private SQLiteCommand DeleteExpiredItemsCommand()
{
var command = new SQLiteCommand("delete from items where expiration <= @exp; select changes()", connection);
command.Parameters.AddWithValue("@exp", DateTimeOffset.UtcNow.Ticks);
return command;
} }
} }
public void Refresh(string key)
{
}
public Task RefreshAsync(string key, CancellationToken token = default)
{
return Task.CompletedTask;
}
public void Remove(string key)
{
if (!string.IsNullOrEmpty(key))
{
try
{
using (var command = DeleteItemCommand(key))
{
command.ExecuteNonQuery();
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Remove({key})", key);
}
}
}
public async Task RemoveAsync(string key, CancellationToken token = default)
{
if (!string.IsNullOrEmpty(key))
{
try
{
using (var command = DeleteItemCommand(key))
{
await command.ExecuteNonQueryAsync();
}
}
catch (Exception ex)
{
logger?.LogError(ex, "RemoveAsync({key})", key);
}
}
}
public void DeleteExpiredItems()
{
long deletedItemsCount;
using (var command = DeleteExpiredItemsCommand())
{
deletedItemsCount = (long)command.ExecuteScalar();
}
if (deletedItemsCount > 0)
{
using (var command = new SQLiteCommand("vacuum", connection))
{
command.ExecuteNonQuery();
}
logger?.LogInformation("Deleted {count} expired items", deletedItemsCount);
}
}
private SQLiteCommand GetItemCommand(string key)
{
var command = new SQLiteCommand("select buffer from items where key = @key and expiration > @exp", connection);
command.Parameters.AddWithValue("@key", key);
command.Parameters.AddWithValue("@exp", DateTimeOffset.UtcNow.Ticks);
return command;
}
private SQLiteCommand SetItemCommand(string key, byte[] buffer, DistributedCacheEntryOptions options)
{
var expiration = options.AbsoluteExpiration ??
DateTimeOffset.UtcNow.Add(options.AbsoluteExpirationRelativeToNow ?? options.SlidingExpiration ?? TimeSpan.FromDays(1));
var command = new SQLiteCommand("insert or replace into items (key, expiration, buffer) values (@key, @exp, @buf)", connection);
command.Parameters.AddWithValue("@key", key);
command.Parameters.AddWithValue("@exp", expiration.UtcTicks);
command.Parameters.AddWithValue("@buf", buffer);
return command;
}
private SQLiteCommand DeleteItemCommand(string key)
{
var command = new SQLiteCommand("delete from items where key = @key", connection);
command.Parameters.AddWithValue("@key", key);
return command;
}
private SQLiteCommand DeleteExpiredItemsCommand()
{
var command = new SQLiteCommand("delete from items where expiration <= @exp; select changes()", connection);
command.Parameters.AddWithValue("@exp", DateTimeOffset.UtcNow.Ticks);
return command;
}
} }

View file

@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework> <TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>14.0</LangVersion>
<RootNamespace>MapControl.Caching</RootNamespace> <RootNamespace>MapControl.Caching</RootNamespace>
<AssemblyTitle>XAML Map Control SQLiteCache Library</AssemblyTitle> <AssemblyTitle>XAML Map Control SQLiteCache Library</AssemblyTitle>
<GeneratePackageOnBuild>$(GeneratePackage)</GeneratePackageOnBuild> <GeneratePackageOnBuild>$(GeneratePackage)</GeneratePackageOnBuild>

View file

@ -11,86 +11,85 @@ using Microsoft.UI.Xaml;
using DependencyProperty = Avalonia.AvaloniaProperty; using DependencyProperty = Avalonia.AvaloniaProperty;
#endif #endif
namespace MapControl.MBTiles namespace MapControl.MBTiles;
/// <summary>
/// MapTileLayer that uses an MBTiles SQLite Database. See https://wiki.openstreetmap.org/wiki/MBTiles.
/// </summary>
public class MBTileLayer : MapTileLayer
{ {
/// <summary> private static ILogger Logger => field ??= ImageLoader.LoggerFactory?.CreateLogger<MBTileLayer>();
/// MapTileLayer that uses an MBTiles SQLite Database. See https://wiki.openstreetmap.org/wiki/MBTiles.
/// </summary> public static readonly DependencyProperty FileProperty =
public class MBTileLayer : MapTileLayer DependencyPropertyHelper.Register<MBTileLayer, string>(nameof(File), null,
async (layer, oldValue, newValue) => await layer.FilePropertyChanged(newValue));
public string File
{ {
private static ILogger Logger => field ??= ImageLoader.LoggerFactory?.CreateLogger<MBTileLayer>(); get => (string)GetValue(FileProperty);
set => SetValue(FileProperty, value);
}
public static readonly DependencyProperty FileProperty = /// <summary>
DependencyPropertyHelper.Register<MBTileLayer, string>(nameof(File), null, /// May be overridden to create a derived MBTileSource that handles other tile formats than png and jpg.
async (layer, oldValue, newValue) => await layer.FilePropertyChanged(newValue)); /// </summary>
protected virtual async Task<MBTileSource> CreateTileSourceAsync(string file)
{
var tileSource = new MBTileSource();
public string File await tileSource.OpenAsync(file);
if (tileSource.Metadata.TryGetValue("format", out string format) && format != "png" && format != "jpg")
{ {
get => (string)GetValue(FileProperty); tileSource.Dispose();
set => SetValue(FileProperty, value);
throw new NotSupportedException($"Tile image format {format} is not supported.");
} }
/// <summary> return tileSource;
/// May be overridden to create a derived MBTileSource that handles other tile formats than png and jpg. }
/// </summary>
protected virtual async Task<MBTileSource> CreateTileSourceAsync(string file) private async Task FilePropertyChanged(string file)
{
(TileSource as MBTileSource)?.Close();
ClearValue(TileSourceProperty);
ClearValue(SourceNameProperty);
ClearValue(DescriptionProperty);
ClearValue(MinZoomLevelProperty);
ClearValue(MaxZoomLevelProperty);
if (!string.IsNullOrEmpty(file))
{ {
var tileSource = new MBTileSource(); try
await tileSource.OpenAsync(file);
if (tileSource.Metadata.TryGetValue("format", out string format) && format != "png" && format != "jpg")
{ {
tileSource.Dispose(); var tileSource = await CreateTileSourceAsync(file);
throw new NotSupportedException($"Tile image format {format} is not supported."); TileSource = tileSource;
if (tileSource.Metadata.TryGetValue("name", out string value))
{
SourceName = value;
}
if (tileSource.Metadata.TryGetValue("description", out value))
{
Description = value;
}
if (tileSource.Metadata.TryGetValue("minzoom", out value) && int.TryParse(value, out int zoomLevel))
{
MinZoomLevel = zoomLevel;
}
if (tileSource.Metadata.TryGetValue("maxzoom", out value) && int.TryParse(value, out zoomLevel))
{
MaxZoomLevel = zoomLevel;
}
} }
catch (Exception ex)
return tileSource;
}
private async Task FilePropertyChanged(string file)
{
(TileSource as MBTileSource)?.Close();
ClearValue(TileSourceProperty);
ClearValue(SourceNameProperty);
ClearValue(DescriptionProperty);
ClearValue(MinZoomLevelProperty);
ClearValue(MaxZoomLevelProperty);
if (!string.IsNullOrEmpty(file))
{ {
try Logger?.LogError(ex, "Invalid file: {file}", file);
{
var tileSource = await CreateTileSourceAsync(file);
TileSource = tileSource;
if (tileSource.Metadata.TryGetValue("name", out string value))
{
SourceName = value;
}
if (tileSource.Metadata.TryGetValue("description", out value))
{
Description = value;
}
if (tileSource.Metadata.TryGetValue("minzoom", out value) && int.TryParse(value, out int zoomLevel))
{
MinZoomLevel = zoomLevel;
}
if (tileSource.Metadata.TryGetValue("maxzoom", out value) && int.TryParse(value, out zoomLevel))
{
MaxZoomLevel = zoomLevel;
}
}
catch (Exception ex)
{
Logger?.LogError(ex, "Invalid file: {file}", file);
}
} }
} }
} }

View file

@ -13,74 +13,73 @@ using Microsoft.UI.Xaml.Media;
using ImageSource = Avalonia.Media.IImage; using ImageSource = Avalonia.Media.IImage;
#endif #endif
namespace MapControl.MBTiles namespace MapControl.MBTiles;
public sealed partial class MBTileSource : TileSource, IDisposable
{ {
public sealed partial class MBTileSource : TileSource, IDisposable private static ILogger Logger => field ??= ImageLoader.LoggerFactory?.CreateLogger<MBTileSource>();
private SQLiteConnection connection;
public IDictionary<string, string> Metadata { get; } = new Dictionary<string, string>();
public async Task OpenAsync(string file)
{ {
private static ILogger Logger => field ??= ImageLoader.LoggerFactory?.CreateLogger<MBTileSource>(); Close();
private SQLiteConnection connection; connection = new SQLiteConnection("Data Source=" + FilePath.GetFullPath(file) + ";Read Only=True");
public IDictionary<string, string> Metadata { get; } = new Dictionary<string, string>(); await connection.OpenAsync();
public async Task OpenAsync(string file) using var command = new SQLiteCommand("select * from metadata", connection);
var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{ {
Close(); Metadata[(string)reader["name"]] = (string)reader["value"];
connection = new SQLiteConnection("Data Source=" + FilePath.GetFullPath(file) + ";Read Only=True");
await connection.OpenAsync();
using var command = new SQLiteCommand("select * from metadata", connection);
var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
Metadata[(string)reader["name"]] = (string)reader["value"];
}
}
public void Close()
{
if (connection != null)
{
Metadata.Clear();
connection.Dispose();
connection = null;
}
}
public void Dispose()
{
Close();
}
public override async Task<ImageSource> LoadImageAsync(int zoomLevel, int column, int row)
{
ImageSource image = null;
try
{
using var command = new SQLiteCommand("select tile_data from tiles where zoom_level=@z and tile_column=@x and tile_row=@y", connection);
command.Parameters.AddWithValue("@z", zoomLevel);
command.Parameters.AddWithValue("@x", column);
command.Parameters.AddWithValue("@y", (1 << zoomLevel) - row - 1);
var buffer = (byte[])await command.ExecuteScalarAsync();
if (buffer?.Length > 0)
{
image = await ImageLoader.LoadImageAsync(buffer);
}
}
catch (Exception ex)
{
Logger?.LogError(ex, "LoadImageAsync");
}
return image;
} }
} }
public void Close()
{
if (connection != null)
{
Metadata.Clear();
connection.Dispose();
connection = null;
}
}
public void Dispose()
{
Close();
}
public override async Task<ImageSource> LoadImageAsync(int zoomLevel, int column, int row)
{
ImageSource image = null;
try
{
using var command = new SQLiteCommand("select tile_data from tiles where zoom_level=@z and tile_column=@x and tile_row=@y", connection);
command.Parameters.AddWithValue("@z", zoomLevel);
command.Parameters.AddWithValue("@x", column);
command.Parameters.AddWithValue("@y", (1 << zoomLevel) - row - 1);
var buffer = (byte[])await command.ExecuteScalarAsync();
if (buffer?.Length > 0)
{
image = await ImageLoader.LoadImageAsync(buffer);
}
}
catch (Exception ex)
{
Logger?.LogError(ex, "LoadImageAsync");
}
return image;
}
} }

View file

@ -6,98 +6,97 @@ using System;
#pragma warning disable AVP1001 #pragma warning disable AVP1001
namespace MapControl namespace MapControl;
public static class DependencyPropertyHelper
{ {
public static class DependencyPropertyHelper public static AttachedProperty<TValue> RegisterAttached<TValue>(
string name,
Type ownerType,
TValue defaultValue = default,
Action<Control, TValue, TValue> changed = null,
bool inherits = false)
{ {
public static AttachedProperty<TValue> RegisterAttached<TValue>( var property = AvaloniaProperty.RegisterAttached<Control, TValue>(name, ownerType, defaultValue, inherits);
string name,
Type ownerType, if (changed != null)
TValue defaultValue = default,
Action<Control, TValue, TValue> changed = null,
bool inherits = false)
{ {
var property = AvaloniaProperty.RegisterAttached<Control, TValue>(name, ownerType, defaultValue, inherits); property.Changed.AddClassHandler<Control, TValue>((o, e) => changed(o, e.OldValue.Value, e.NewValue.Value));
if (changed != null)
{
property.Changed.AddClassHandler<Control, TValue>((o, e) => changed(o, e.OldValue.Value, e.NewValue.Value));
}
return property;
} }
public static StyledProperty<TValue> Register<TOwner, TValue>( return property;
string name, }
TValue defaultValue = default,
Action<TOwner, TValue, TValue> changed = null, public static StyledProperty<TValue> Register<TOwner, TValue>(
Func<TOwner, TValue, TValue> coerce = null, string name,
bool bindTwoWayByDefault = false) TValue defaultValue = default,
where TOwner : AvaloniaObject Action<TOwner, TValue, TValue> changed = null,
Func<TOwner, TValue, TValue> coerce = null,
bool bindTwoWayByDefault = false)
where TOwner : AvaloniaObject
{
Func<AvaloniaObject, TValue, TValue> coerceFunc = null;
if (coerce != null)
{ {
Func<AvaloniaObject, TValue, TValue> coerceFunc = null; // Do not coerce default value.
//
if (coerce != null) coerceFunc = (obj, value) => Equals(value, defaultValue) ? value : coerce((TOwner)obj, value);
{
// Do not coerce default value.
//
coerceFunc = (obj, value) => Equals(value, defaultValue) ? value : coerce((TOwner)obj, value);
}
var bindingMode = bindTwoWayByDefault ? BindingMode.TwoWay : BindingMode.OneWay;
var property = AvaloniaProperty.Register<TOwner, TValue>(name, defaultValue, false, bindingMode, null, coerceFunc);
if (changed != null)
{
property.Changed.AddClassHandler<TOwner, TValue>((o, e) => changed(o, e.OldValue.Value, e.NewValue.Value));
}
return property;
} }
public static StyledProperty<TValue> AddOwner<TOwner, TValue>( var bindingMode = bindTwoWayByDefault ? BindingMode.TwoWay : BindingMode.OneWay;
StyledProperty<TValue> source) where TOwner : AvaloniaObject
{
return source.AddOwner<TOwner>();
}
public static StyledProperty<TValue> AddOwner<TOwner, TValue>( var property = AvaloniaProperty.Register<TOwner, TValue>(name, defaultValue, false, bindingMode, null, coerceFunc);
StyledProperty<TValue> source,
TValue defaultValue) where TOwner : AvaloniaObject
{
return source.AddOwner<TOwner>(new StyledPropertyMetadata<TValue>(new Optional<TValue>(defaultValue)));
}
public static StyledProperty<TValue> AddOwner<TOwner, TValue>( if (changed != null)
StyledProperty<TValue> source,
Action<TOwner, TValue, TValue> changed) where TOwner : AvaloniaObject
{ {
var property = source.AddOwner<TOwner>();
property.Changed.AddClassHandler<TOwner, TValue>((o, e) => changed(o, e.OldValue.Value, e.NewValue.Value)); property.Changed.AddClassHandler<TOwner, TValue>((o, e) => changed(o, e.OldValue.Value, e.NewValue.Value));
return property;
} }
public static StyledProperty<TValue> AddOwner<TOwner, TValue>( return property;
string _, // for compatibility with WinUI/UWP DependencyPropertyHelper }
StyledProperty<TValue> source) where TOwner : AvaloniaObject
{
return AddOwner<TOwner, TValue>(source);
}
public static StyledProperty<TValue> AddOwner<TOwner, TValue>( public static StyledProperty<TValue> AddOwner<TOwner, TValue>(
string _, // for compatibility with WinUI/UWP DependencyPropertyHelper StyledProperty<TValue> source) where TOwner : AvaloniaObject
StyledProperty<TValue> source, {
Action<TOwner, TValue, TValue> changed) where TOwner : AvaloniaObject return source.AddOwner<TOwner>();
{ }
return AddOwner(source, changed);
}
public static void SetBinding(this AvaloniaObject target, AvaloniaProperty property, Binding binding) public static StyledProperty<TValue> AddOwner<TOwner, TValue>(
{ StyledProperty<TValue> source,
target.Bind(property, binding); TValue defaultValue) where TOwner : AvaloniaObject
} {
return source.AddOwner<TOwner>(new StyledPropertyMetadata<TValue>(new Optional<TValue>(defaultValue)));
}
public static StyledProperty<TValue> AddOwner<TOwner, TValue>(
StyledProperty<TValue> source,
Action<TOwner, TValue, TValue> changed) where TOwner : AvaloniaObject
{
var property = source.AddOwner<TOwner>();
property.Changed.AddClassHandler<TOwner, TValue>((o, e) => changed(o, e.OldValue.Value, e.NewValue.Value));
return property;
}
public static StyledProperty<TValue> AddOwner<TOwner, TValue>(
string _, // for compatibility with WinUI/UWP DependencyPropertyHelper
StyledProperty<TValue> source) where TOwner : AvaloniaObject
{
return AddOwner<TOwner, TValue>(source);
}
public static StyledProperty<TValue> AddOwner<TOwner, TValue>(
string _, // for compatibility with WinUI/UWP DependencyPropertyHelper
StyledProperty<TValue> source,
Action<TOwner, TValue, TValue> changed) where TOwner : AvaloniaObject
{
return AddOwner(source, changed);
}
public static void SetBinding(this AvaloniaObject target, AvaloniaProperty property, Binding binding)
{
target.Bind(property, binding);
} }
} }

View file

@ -1,13 +1,12 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace MapControl namespace MapControl;
public static partial class GeoImage
{ {
public static partial class GeoImage private static Task<GeoBitmap> LoadGeoTiff(string sourcePath)
{ {
private static Task<GeoBitmap> LoadGeoTiff(string sourcePath) throw new InvalidOperationException("GeoTIFF is not supported.");
{
throw new InvalidOperationException("GeoTIFF is not supported.");
}
} }
} }

View file

@ -7,79 +7,78 @@ using System.IO;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace MapControl namespace MapControl;
public static partial class ImageLoader
{ {
public static partial class ImageLoader public static IImage LoadResourceImage(Uri uri)
{ {
public static IImage LoadResourceImage(Uri uri) return new Bitmap(AssetLoader.Open(uri));
}
public static IImage LoadImage(Stream stream)
{
return new Bitmap(stream);
}
public static IImage LoadImage(string path)
{
return File.Exists(path) ? new Bitmap(path) : null;
}
public static Task<IImage> LoadImageAsync(Stream stream)
{
return Thread.CurrentThread.IsThreadPoolThread ?
Task.FromResult(LoadImage(stream)) :
Task.Run(() => LoadImage(stream));
}
public static Task<IImage> LoadImageAsync(string path)
{
return Thread.CurrentThread.IsThreadPoolThread ?
Task.FromResult(LoadImage(path)) :
Task.Run(() => LoadImage(path));
}
internal static async Task<IImage> LoadMergedImageAsync(Uri uri1, Uri uri2, IProgress<double> progress)
{
Bitmap mergedBitmap = null;
var p1 = 0d;
var p2 = 0d;
var images = await Task.WhenAll(
LoadImageAsync(uri1, new Progress<double>(p => { p1 = p; progress.Report((p1 + p2) / 2d); })),
LoadImageAsync(uri2, new Progress<double>(p => { p2 = p; progress.Report((p1 + p2) / 2d); })));
if (images.Length == 2 &&
images[0] is Bitmap bitmap1 &&
images[1] is Bitmap bitmap2 &&
bitmap1.PixelSize.Height == bitmap2.PixelSize.Height &&
bitmap1.Format.HasValue &&
bitmap1.Format == bitmap2.Format &&
bitmap1.AlphaFormat.HasValue &&
bitmap1.AlphaFormat == bitmap2.AlphaFormat)
{ {
return new Bitmap(AssetLoader.Open(uri)); var bpp = bitmap1.Format.Value == PixelFormat.Rgb565 ? 2 : 4;
} var pixelSize = new PixelSize(bitmap1.PixelSize.Width + bitmap2.PixelSize.Width, bitmap1.PixelSize.Height);
var stride1 = bpp * bitmap1.PixelSize.Width;
var stride = bpp * pixelSize.Width;
var bufferSize = stride * pixelSize.Height;
public static IImage LoadImage(Stream stream) unsafe
{
return new Bitmap(stream);
}
public static IImage LoadImage(string path)
{
return File.Exists(path) ? new Bitmap(path) : null;
}
public static Task<IImage> LoadImageAsync(Stream stream)
{
return Thread.CurrentThread.IsThreadPoolThread ?
Task.FromResult(LoadImage(stream)) :
Task.Run(() => LoadImage(stream));
}
public static Task<IImage> LoadImageAsync(string path)
{
return Thread.CurrentThread.IsThreadPoolThread ?
Task.FromResult(LoadImage(path)) :
Task.Run(() => LoadImage(path));
}
internal static async Task<IImage> LoadMergedImageAsync(Uri uri1, Uri uri2, IProgress<double> progress)
{
Bitmap mergedBitmap = null;
var p1 = 0d;
var p2 = 0d;
var images = await Task.WhenAll(
LoadImageAsync(uri1, new Progress<double>(p => { p1 = p; progress.Report((p1 + p2) / 2d); })),
LoadImageAsync(uri2, new Progress<double>(p => { p2 = p; progress.Report((p1 + p2) / 2d); })));
if (images.Length == 2 &&
images[0] is Bitmap bitmap1 &&
images[1] is Bitmap bitmap2 &&
bitmap1.PixelSize.Height == bitmap2.PixelSize.Height &&
bitmap1.Format.HasValue &&
bitmap1.Format == bitmap2.Format &&
bitmap1.AlphaFormat.HasValue &&
bitmap1.AlphaFormat == bitmap2.AlphaFormat)
{ {
var bpp = bitmap1.Format.Value == PixelFormat.Rgb565 ? 2 : 4; fixed (byte* ptr = new byte[stride * pixelSize.Height])
var pixelSize = new PixelSize(bitmap1.PixelSize.Width + bitmap2.PixelSize.Width, bitmap1.PixelSize.Height);
var stride1 = bpp * bitmap1.PixelSize.Width;
var stride = bpp * pixelSize.Width;
var bufferSize = stride * pixelSize.Height;
unsafe
{ {
fixed (byte* ptr = new byte[stride * pixelSize.Height]) var buffer = (nint)ptr;
{
var buffer = (nint)ptr;
bitmap1.CopyPixels(new PixelRect(bitmap1.PixelSize), buffer, bufferSize, stride); bitmap1.CopyPixels(new PixelRect(bitmap1.PixelSize), buffer, bufferSize, stride);
bitmap2.CopyPixels(new PixelRect(bitmap2.PixelSize), buffer + stride1, bufferSize, stride); bitmap2.CopyPixels(new PixelRect(bitmap2.PixelSize), buffer + stride1, bufferSize, stride);
mergedBitmap = new Bitmap(bitmap1.Format.Value, bitmap1.AlphaFormat.Value, buffer, pixelSize, bitmap1.Dpi, stride); mergedBitmap = new Bitmap(bitmap1.Format.Value, bitmap1.AlphaFormat.Value, buffer, pixelSize, bitmap1.Dpi, stride);
}
} }
} }
return mergedBitmap;
} }
return mergedBitmap;
} }
} }

View file

@ -6,46 +6,45 @@ using Avalonia.Styling;
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace MapControl namespace MapControl;
public class ImageTile(int zoomLevel, int x, int y, int columnCount)
: Tile(zoomLevel, x, y, columnCount)
{ {
public class ImageTile(int zoomLevel, int x, int y, int columnCount) public Image Image { get; } = new Image { Stretch = Stretch.Fill };
: Tile(zoomLevel, x, y, columnCount)
public override async Task LoadImageAsync(Func<Task<IImage>> loadImageFunc)
{ {
public Image Image { get; } = new Image { Stretch = Stretch.Fill }; var image = await loadImageFunc().ConfigureAwait(false);
public override async Task LoadImageAsync(Func<Task<IImage>> loadImageFunc) void SetImageSource()
{ {
var image = await loadImageFunc().ConfigureAwait(false); Image.Source = image;
void SetImageSource() if (image != null && MapBase.ImageFadeDuration > TimeSpan.Zero)
{ {
Image.Source = image; var fadeInAnimation = new Animation
if (image != null && MapBase.ImageFadeDuration > TimeSpan.Zero)
{ {
var fadeInAnimation = new Animation Duration = MapBase.ImageFadeDuration,
{ Children =
Duration = MapBase.ImageFadeDuration, {
Children = new KeyFrame
{ {
new KeyFrame KeyTime = TimeSpan.Zero,
{ Setters = { new Setter(Visual.OpacityProperty, 0d) }
KeyTime = TimeSpan.Zero, },
Setters = { new Setter(Visual.OpacityProperty, 0d) } new KeyFrame
}, {
new KeyFrame KeyTime = MapBase.ImageFadeDuration,
{ Setters = { new Setter(Visual.OpacityProperty, 1d) }
KeyTime = MapBase.ImageFadeDuration,
Setters = { new Setter(Visual.OpacityProperty, 1d) }
}
} }
}; }
};
_ = fadeInAnimation.RunAsync(Image); _ = fadeInAnimation.RunAsync(Image);
}
} }
await Image.Dispatcher.InvokeAsync(SetImageSource);
} }
await Image.Dispatcher.InvokeAsync(SetImageSource);
} }
} }

View file

@ -1,14 +1,13 @@
using Avalonia.Animation; using Avalonia.Animation;
namespace MapControl namespace MapControl;
public class LocationAnimator : InterpolatingAnimator<Location>
{ {
public class LocationAnimator : InterpolatingAnimator<Location> public override Location Interpolate(double progress, Location oldValue, Location newValue)
{ {
public override Location Interpolate(double progress, Location oldValue, Location newValue) return new Location(
{ (1d - progress) * oldValue.Latitude + progress * newValue.Latitude,
return new Location( (1d - progress) * oldValue.Longitude + progress * newValue.Longitude);
(1d - progress) * oldValue.Latitude + progress * newValue.Latitude,
(1d - progress) * oldValue.Longitude + progress * newValue.Longitude);
}
} }
} }

View file

@ -2,140 +2,139 @@
using Avalonia.Input; using Avalonia.Input;
using System; using System;
namespace MapControl namespace MapControl;
[Flags]
public enum ManipulationModes
{ {
[Flags] None = 0,
public enum ManipulationModes Translate = 1,
Rotate = 2,
Scale = 4,
All = Translate | Rotate | Scale
}
public partial class Map
{
public static readonly StyledProperty<ManipulationModes> ManipulationModeProperty =
DependencyPropertyHelper.Register<Map, ManipulationModes>(nameof(ManipulationMode), ManipulationModes.Translate | ManipulationModes.Scale);
/// <summary>
/// Gets or sets a value that specifies how the map control handles manipulations.
/// </summary>
public ManipulationModes ManipulationMode
{ {
None = 0, get => GetValue(ManipulationModeProperty);
Translate = 1, set => SetValue(ManipulationModeProperty, value);
Rotate = 2,
Scale = 4,
All = Translate | Rotate | Scale
} }
public partial class Map protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
{ {
public static readonly StyledProperty<ManipulationModes> ManipulationModeProperty = base.OnPointerWheelChanged(e);
DependencyPropertyHelper.Register<Map, ManipulationModes>(nameof(ManipulationMode), ManipulationModes.Translate | ManipulationModes.Scale);
/// <summary> OnMouseWheel(e.GetPosition(this), e.Delta.Y);
/// Gets or sets a value that specifies how the map control handles manipulations. }
/// </summary>
public ManipulationModes ManipulationMode private IPointer pointer1;
private IPointer pointer2;
private Point position1;
private Point position2;
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
var point = e.GetCurrentPoint(this);
if (point.Pointer == pointer1 || point.Pointer == pointer2)
{ {
get => GetValue(ManipulationModeProperty); if (pointer2 != null)
set => SetValue(ManipulationModeProperty, value);
}
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
{
base.OnPointerWheelChanged(e);
OnMouseWheel(e.GetPosition(this), e.Delta.Y);
}
private IPointer pointer1;
private IPointer pointer2;
private Point position1;
private Point position2;
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
var point = e.GetCurrentPoint(this);
if (point.Pointer == pointer1 || point.Pointer == pointer2)
{ {
if (pointer2 != null) HandleManipulation(point.Pointer, point.Position);
{
HandleManipulation(point.Pointer, point.Position);
}
else if (point.Pointer.Type == PointerType.Mouse ||
ManipulationMode.HasFlag(ManipulationModes.Translate))
{
TranslateMap(new Point(point.Position.X - position1.X, point.Position.Y - position1.Y));
position1 = point.Position;
}
} }
else if (pointer1 == null && else if (point.Pointer.Type == PointerType.Mouse ||
point.Pointer.Type == PointerType.Mouse && ManipulationMode.HasFlag(ManipulationModes.Translate))
point.Properties.IsLeftButtonPressed &&
e.KeyModifiers == KeyModifiers.None ||
pointer2 == null &&
point.Pointer.Type == PointerType.Touch &&
ManipulationMode != ManipulationModes.None)
{ {
point.Pointer.Capture(this); TranslateMap(new Point(point.Position.X - position1.X, point.Position.Y - position1.Y));
position1 = point.Position;
if (pointer1 == null)
{
pointer1 = point.Pointer;
position1 = point.Position;
}
else
{
pointer2 = point.Pointer;
position2 = point.Position;
}
} }
} }
else if (pointer1 == null &&
protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) point.Pointer.Type == PointerType.Mouse &&
point.Properties.IsLeftButtonPressed &&
e.KeyModifiers == KeyModifiers.None ||
pointer2 == null &&
point.Pointer.Type == PointerType.Touch &&
ManipulationMode != ManipulationModes.None)
{ {
base.OnPointerCaptureLost(e); point.Pointer.Capture(this);
if (e.Pointer == pointer1 || e.Pointer == pointer2) if (pointer1 == null)
{ {
if (e.Pointer == pointer1) pointer1 = point.Pointer;
{ position1 = point.Position;
pointer1 = pointer2;
position1 = position2;
}
pointer2 = null;
}
}
private void HandleManipulation(IPointer pointer, Point position)
{
var oldDistance = new Vector(position2.X - position1.X, position2.Y - position1.Y);
var oldOrigin = new Point((position1.X + position2.X) / 2d, (position1.Y + position2.Y) / 2d);
if (pointer == pointer1)
{
position1 = position;
} }
else else
{ {
position2 = position; pointer2 = point.Pointer;
position2 = point.Position;
} }
var newDistance = new Vector(position2.X - position1.X, position2.Y - position1.Y);
var newOrigin = oldOrigin;
var translation = new Point();
var rotation = 0d;
var scale = 1d;
if (ManipulationMode.HasFlag(ManipulationModes.Translate))
{
newOrigin = new Point((position1.X + position2.X) / 2d, (position1.Y + position2.Y) / 2d);
translation = newOrigin - oldOrigin;
}
if (ManipulationMode.HasFlag(ManipulationModes.Rotate))
{
rotation = 180d / Math.PI *
(Math.Atan2(newDistance.Y, newDistance.X) - Math.Atan2(oldDistance.Y, oldDistance.X));
}
if (ManipulationMode.HasFlag(ManipulationModes.Scale))
{
scale = newDistance.Length / oldDistance.Length;
}
TransformMap(newOrigin, translation, rotation, scale);
} }
} }
protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e)
{
base.OnPointerCaptureLost(e);
if (e.Pointer == pointer1 || e.Pointer == pointer2)
{
if (e.Pointer == pointer1)
{
pointer1 = pointer2;
position1 = position2;
}
pointer2 = null;
}
}
private void HandleManipulation(IPointer pointer, Point position)
{
var oldDistance = new Vector(position2.X - position1.X, position2.Y - position1.Y);
var oldOrigin = new Point((position1.X + position2.X) / 2d, (position1.Y + position2.Y) / 2d);
if (pointer == pointer1)
{
position1 = position;
}
else
{
position2 = position;
}
var newDistance = new Vector(position2.X - position1.X, position2.Y - position1.Y);
var newOrigin = oldOrigin;
var translation = new Point();
var rotation = 0d;
var scale = 1d;
if (ManipulationMode.HasFlag(ManipulationModes.Translate))
{
newOrigin = new Point((position1.X + position2.X) / 2d, (position1.Y + position2.Y) / 2d);
translation = newOrigin - oldOrigin;
}
if (ManipulationMode.HasFlag(ManipulationModes.Rotate))
{
rotation = 180d / Math.PI *
(Math.Atan2(newDistance.Y, newDistance.X) - Math.Atan2(oldDistance.Y, oldDistance.X));
}
if (ManipulationMode.HasFlag(ManipulationModes.Scale))
{
scale = newDistance.Length / oldDistance.Length;
}
TransformMap(newOrigin, translation, rotation, scale);
}
} }

View file

@ -10,266 +10,265 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Brush = Avalonia.Media.IBrush; using Brush = Avalonia.Media.IBrush;
namespace MapControl namespace MapControl;
public partial class MapBase
{ {
public partial class MapBase public static readonly StyledProperty<Brush> ForegroundProperty =
DependencyPropertyHelper.AddOwner<MapBase, Brush>(TextElement.ForegroundProperty, Brushes.Black);
public static readonly StyledProperty<Easing> AnimationEasingProperty =
DependencyPropertyHelper.Register<MapBase, Easing>(nameof(AnimationEasing), new QuadraticEaseOut());
public static readonly StyledProperty<Location> CenterProperty =
DependencyPropertyHelper.Register<MapBase, Location>(nameof(Center), new Location(0d, 0d),
(map, oldValue, newValue) => map.CenterPropertyChanged(newValue),
(map, value) => map.CoerceCenterProperty(value),
true);
public static readonly StyledProperty<Location> TargetCenterProperty =
DependencyPropertyHelper.Register<MapBase, Location>(nameof(TargetCenter), new Location(0d, 0d),
async (map, oldValue, newValue) => await map.TargetCenterPropertyChanged(newValue),
(map, value) => map.CoerceCenterProperty(value),
true);
public static readonly StyledProperty<double> MinZoomLevelProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(MinZoomLevel), 1d,
(map, oldValue, newValue) => map.MinZoomLevelPropertyChanged(newValue),
(map, value) => map.CoerceMinZoomLevelProperty(value));
public static readonly StyledProperty<double> MaxZoomLevelProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(MaxZoomLevel), 20d,
(map, oldValue, newValue) => map.MaxZoomLevelPropertyChanged(newValue),
(map, value) => map.CoerceMaxZoomLevelProperty(value));
public static readonly StyledProperty<double> ZoomLevelProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(ZoomLevel), 1d,
(map, oldValue, newValue) => map.ZoomLevelPropertyChanged(newValue),
(map, value) => map.CoerceZoomLevelProperty(value),
true);
public static readonly StyledProperty<double> TargetZoomLevelProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(TargetZoomLevel), 1d,
async (map, oldValue, newValue) => await map.TargetZoomLevelPropertyChanged(newValue),
(map, value) => map.CoerceZoomLevelProperty(value),
true);
public static readonly StyledProperty<double> HeadingProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(Heading), 0d,
(map, oldValue, newValue) => map.HeadingPropertyChanged(newValue),
(map, value) => map.CoerceHeadingProperty(value),
true);
public static readonly StyledProperty<double> TargetHeadingProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(TargetHeading), 0d,
async (map, oldValue, newValue) => await map.TargetHeadingPropertyChanged(newValue),
(map, value) => map.CoerceHeadingProperty(value),
true);
public static readonly DirectProperty<MapBase, double> ViewScaleProperty =
AvaloniaProperty.RegisterDirect<MapBase, double>(nameof(ViewScale), map => map.ViewTransform.Scale);
private CancellationTokenSource centerCts;
private CancellationTokenSource zoomLevelCts;
private CancellationTokenSource headingCts;
private Animation centerAnimation;
private Animation zoomLevelAnimation;
private Animation headingAnimation;
static MapBase()
{ {
public static readonly StyledProperty<Brush> ForegroundProperty = BackgroundProperty.OverrideDefaultValue<MapBase>(Brushes.White);
DependencyPropertyHelper.AddOwner<MapBase, Brush>(TextElement.ForegroundProperty, Brushes.Black); ClipToBoundsProperty.OverrideDefaultValue<MapBase>(true);
public static readonly StyledProperty<Easing> AnimationEasingProperty = Animation.RegisterCustomAnimator<Location, LocationAnimator>();
DependencyPropertyHelper.Register<MapBase, Easing>(nameof(AnimationEasing), new QuadraticEaseOut()); }
public static readonly StyledProperty<Location> CenterProperty = public double ActualWidth => Bounds.Width;
DependencyPropertyHelper.Register<MapBase, Location>(nameof(Center), new Location(0d, 0d), public double ActualHeight => Bounds.Height;
(map, oldValue, newValue) => map.CenterPropertyChanged(newValue),
(map, value) => map.CoerceCenterProperty(value),
true);
public static readonly StyledProperty<Location> TargetCenterProperty = protected override void OnSizeChanged(SizeChangedEventArgs e)
DependencyPropertyHelper.Register<MapBase, Location>(nameof(TargetCenter), new Location(0d, 0d), {
async (map, oldValue, newValue) => await map.TargetCenterPropertyChanged(newValue), base.OnSizeChanged(e);
(map, value) => map.CoerceCenterProperty(value),
true);
public static readonly StyledProperty<double> MinZoomLevelProperty = ResetTransformCenter();
DependencyPropertyHelper.Register<MapBase, double>(nameof(MinZoomLevel), 1d, UpdateTransform();
(map, oldValue, newValue) => map.MinZoomLevelPropertyChanged(newValue), }
(map, value) => map.CoerceMinZoomLevelProperty(value));
public static readonly StyledProperty<double> MaxZoomLevelProperty = /// <summary>
DependencyPropertyHelper.Register<MapBase, double>(nameof(MaxZoomLevel), 20d, /// Gets or sets the Easing of the Center, ZoomLevel and Heading animations.
(map, oldValue, newValue) => map.MaxZoomLevelPropertyChanged(newValue), /// The default value is a QuadraticEaseOut.
(map, value) => map.CoerceMaxZoomLevelProperty(value)); /// </summary>
public Easing AnimationEasing
{
get => GetValue(AnimationEasingProperty);
set => SetValue(AnimationEasingProperty, value);
}
public static readonly StyledProperty<double> ZoomLevelProperty = /// <summary>
DependencyPropertyHelper.Register<MapBase, double>(nameof(ZoomLevel), 1d, /// Gets the scaling factor from projected map coordinates to view coordinates,
(map, oldValue, newValue) => map.ZoomLevelPropertyChanged(newValue), /// as pixels per meter.
(map, value) => map.CoerceZoomLevelProperty(value), /// </summary>
true); public double ViewScale
{
get => GetValue(ViewScaleProperty);
private set => RaisePropertyChanged(ViewScaleProperty, double.NaN, value);
}
public static readonly StyledProperty<double> TargetZoomLevelProperty = private void CenterPropertyChanged(Location center)
DependencyPropertyHelper.Register<MapBase, double>(nameof(TargetZoomLevel), 1d, {
async (map, oldValue, newValue) => await map.TargetZoomLevelPropertyChanged(newValue), if (!internalPropertyChange)
(map, value) => map.CoerceZoomLevelProperty(value),
true);
public static readonly StyledProperty<double> HeadingProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(Heading), 0d,
(map, oldValue, newValue) => map.HeadingPropertyChanged(newValue),
(map, value) => map.CoerceHeadingProperty(value),
true);
public static readonly StyledProperty<double> TargetHeadingProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(TargetHeading), 0d,
async (map, oldValue, newValue) => await map.TargetHeadingPropertyChanged(newValue),
(map, value) => map.CoerceHeadingProperty(value),
true);
public static readonly DirectProperty<MapBase, double> ViewScaleProperty =
AvaloniaProperty.RegisterDirect<MapBase, double>(nameof(ViewScale), map => map.ViewTransform.Scale);
private CancellationTokenSource centerCts;
private CancellationTokenSource zoomLevelCts;
private CancellationTokenSource headingCts;
private Animation centerAnimation;
private Animation zoomLevelAnimation;
private Animation headingAnimation;
static MapBase()
{ {
BackgroundProperty.OverrideDefaultValue<MapBase>(Brushes.White);
ClipToBoundsProperty.OverrideDefaultValue<MapBase>(true);
Animation.RegisterCustomAnimator<Location, LocationAnimator>();
}
public double ActualWidth => Bounds.Width;
public double ActualHeight => Bounds.Height;
protected override void OnSizeChanged(SizeChangedEventArgs e)
{
base.OnSizeChanged(e);
ResetTransformCenter();
UpdateTransform(); UpdateTransform();
}
/// <summary> if (centerAnimation == null)
/// Gets or sets the Easing of the Center, ZoomLevel and Heading animations.
/// The default value is a QuadraticEaseOut.
/// </summary>
public Easing AnimationEasing
{
get => GetValue(AnimationEasingProperty);
set => SetValue(AnimationEasingProperty, value);
}
/// <summary>
/// Gets the scaling factor from projected map coordinates to view coordinates,
/// as pixels per meter.
/// </summary>
public double ViewScale
{
get => GetValue(ViewScaleProperty);
private set => RaisePropertyChanged(ViewScaleProperty, double.NaN, value);
}
private void CenterPropertyChanged(Location center)
{
if (!internalPropertyChange)
{ {
UpdateTransform(); SetValueInternal(TargetCenterProperty, center);
if (centerAnimation == null)
{
SetValueInternal(TargetCenterProperty, center);
}
} }
} }
}
private async Task TargetCenterPropertyChanged(Location targetCenter)
{ private async Task TargetCenterPropertyChanged(Location targetCenter)
if (!internalPropertyChange && !targetCenter.Equals(Center)) {
{ if (!internalPropertyChange && !targetCenter.Equals(Center))
ResetTransformCenter(); {
ResetTransformCenter();
centerCts?.Cancel();
centerCts?.Cancel();
centerAnimation = CreateAnimation(CenterProperty, new Location(targetCenter.Latitude, NearestLongitude(targetCenter.Longitude)));
centerAnimation = CreateAnimation(CenterProperty, new Location(targetCenter.Latitude, NearestLongitude(targetCenter.Longitude)));
using (centerCts = new CancellationTokenSource())
{ using (centerCts = new CancellationTokenSource())
await centerAnimation.RunAsync(this, centerCts.Token); {
await centerAnimation.RunAsync(this, centerCts.Token);
if (!centerCts.IsCancellationRequested)
{ if (!centerCts.IsCancellationRequested)
UpdateTransform(); {
} UpdateTransform();
} }
}
centerCts = null;
centerAnimation = null; centerCts = null;
} centerAnimation = null;
} }
}
private void MinZoomLevelPropertyChanged(double minZoomLevel)
{ private void MinZoomLevelPropertyChanged(double minZoomLevel)
if (ZoomLevel < minZoomLevel) {
{ if (ZoomLevel < minZoomLevel)
ZoomLevel = minZoomLevel; {
} ZoomLevel = minZoomLevel;
} }
}
private void MaxZoomLevelPropertyChanged(double maxZoomLevel)
{ private void MaxZoomLevelPropertyChanged(double maxZoomLevel)
if (ZoomLevel > maxZoomLevel) {
{ if (ZoomLevel > maxZoomLevel)
ZoomLevel = maxZoomLevel; {
} ZoomLevel = maxZoomLevel;
} }
}
private void ZoomLevelPropertyChanged(double zoomLevel)
{ private void ZoomLevelPropertyChanged(double zoomLevel)
if (!internalPropertyChange) {
{ if (!internalPropertyChange)
UpdateTransform(); {
UpdateTransform();
if (zoomLevelAnimation == null)
{ if (zoomLevelAnimation == null)
SetValueInternal(TargetZoomLevelProperty, zoomLevel); {
} SetValueInternal(TargetZoomLevelProperty, zoomLevel);
} }
} }
}
private async Task TargetZoomLevelPropertyChanged(double targetZoomLevel)
{ private async Task TargetZoomLevelPropertyChanged(double targetZoomLevel)
if (!internalPropertyChange && targetZoomLevel != ZoomLevel) {
{ if (!internalPropertyChange && targetZoomLevel != ZoomLevel)
zoomLevelCts?.Cancel(); {
zoomLevelCts?.Cancel();
zoomLevelAnimation = CreateAnimation(ZoomLevelProperty, targetZoomLevel);
zoomLevelAnimation = CreateAnimation(ZoomLevelProperty, targetZoomLevel);
using (zoomLevelCts = new CancellationTokenSource())
{ using (zoomLevelCts = new CancellationTokenSource())
await zoomLevelAnimation.RunAsync(this, zoomLevelCts.Token); {
await zoomLevelAnimation.RunAsync(this, zoomLevelCts.Token);
if (!zoomLevelCts.IsCancellationRequested)
{ if (!zoomLevelCts.IsCancellationRequested)
UpdateTransform(true); // reset transform center {
} UpdateTransform(true); // reset transform center
} }
}
zoomLevelCts = null;
zoomLevelAnimation = null; zoomLevelCts = null;
} zoomLevelAnimation = null;
} }
}
private void HeadingPropertyChanged(double heading)
{ private void HeadingPropertyChanged(double heading)
if (!internalPropertyChange) {
{ if (!internalPropertyChange)
UpdateTransform(); {
UpdateTransform();
if (headingAnimation == null)
{ if (headingAnimation == null)
SetValueInternal(TargetHeadingProperty, heading); {
} SetValueInternal(TargetHeadingProperty, heading);
} }
} }
}
private async Task TargetHeadingPropertyChanged(double targetHeading)
{ private async Task TargetHeadingPropertyChanged(double targetHeading)
if (!internalPropertyChange && targetHeading != Heading) {
{ if (!internalPropertyChange && targetHeading != Heading)
var delta = targetHeading - Heading; {
var delta = targetHeading - Heading;
if (delta > 180d)
{ if (delta > 180d)
delta -= 360d; {
} delta -= 360d;
else if (delta < -180d) }
{ else if (delta < -180d)
delta += 360d; {
} delta += 360d;
}
targetHeading = Heading + delta;
targetHeading = Heading + delta;
headingCts?.Cancel();
headingCts?.Cancel();
headingAnimation = CreateAnimation(HeadingProperty, targetHeading);
headingAnimation = CreateAnimation(HeadingProperty, targetHeading);
using (headingCts = new CancellationTokenSource())
{ using (headingCts = new CancellationTokenSource())
await headingAnimation.RunAsync(this, headingCts.Token); {
await headingAnimation.RunAsync(this, headingCts.Token);
if (!headingCts.IsCancellationRequested)
{ if (!headingCts.IsCancellationRequested)
UpdateTransform(); {
} UpdateTransform();
} }
}
headingCts = null;
headingAnimation = null; headingCts = null;
} headingAnimation = null;
} }
}
private Animation CreateAnimation(DependencyProperty property, object value)
{ private Animation CreateAnimation(DependencyProperty property, object value)
return new Animation {
{ return new Animation
FillMode = FillMode.Forward, {
Duration = AnimationDuration, FillMode = FillMode.Forward,
Easing = AnimationEasing, Duration = AnimationDuration,
Children = Easing = AnimationEasing,
{ Children =
new KeyFrame {
{ new KeyFrame
KeyTime = AnimationDuration, {
Setters = { new Setter(property, value) } KeyTime = AnimationDuration,
} Setters = { new Setter(property, value) }
} }
}; }
} };
} }
} }

View file

@ -1,6 +0,0 @@
using Avalonia;
using Avalonia.Controls;
namespace MapControl
{
}

View file

@ -7,116 +7,115 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
namespace MapControl namespace MapControl;
public partial class MapGrid : Control, IMapElement
{ {
public partial class MapGrid : Control, IMapElement static MapGrid()
{ {
static MapGrid() AffectsRender<MapGrid>(ForegroundProperty);
}
public static readonly StyledProperty<IBrush> ForegroundProperty =
DependencyPropertyHelper.AddOwner<MapGrid, IBrush>(TextElement.ForegroundProperty);
public static readonly StyledProperty<FontFamily> FontFamilyProperty =
DependencyPropertyHelper.AddOwner<MapGrid, FontFamily>(TextElement.FontFamilyProperty);
public static readonly StyledProperty<double> FontSizeProperty =
DependencyPropertyHelper.AddOwner<MapGrid, double>(TextElement.FontSizeProperty, 12d);
/// <summary>
/// Implements IMapElement.ParentMap.
/// </summary>
public MapBase ParentMap
{
get;
set
{ {
AffectsRender<MapGrid>(ForegroundProperty); if (field != null)
}
public static readonly StyledProperty<IBrush> ForegroundProperty =
DependencyPropertyHelper.AddOwner<MapGrid, IBrush>(TextElement.ForegroundProperty);
public static readonly StyledProperty<FontFamily> FontFamilyProperty =
DependencyPropertyHelper.AddOwner<MapGrid, FontFamily>(TextElement.FontFamilyProperty);
public static readonly StyledProperty<double> FontSizeProperty =
DependencyPropertyHelper.AddOwner<MapGrid, double>(TextElement.FontSizeProperty, 12d);
/// <summary>
/// Implements IMapElement.ParentMap.
/// </summary>
public MapBase ParentMap
{
get;
set
{ {
if (field != null) field.ViewportChanged -= OnViewportChanged;
{ }
field.ViewportChanged -= OnViewportChanged;
}
field = value; field = value;
if (field != null) if (field != null)
{ {
field.ViewportChanged += OnViewportChanged; field.ViewportChanged += OnViewportChanged;
}
} }
} }
}
private void OnViewportChanged(object sender, ViewportChangedEventArgs e) private void OnViewportChanged(object sender, ViewportChangedEventArgs e)
{ {
OnViewportChanged(e); OnViewportChanged(e);
} }
protected virtual void OnViewportChanged(ViewportChangedEventArgs e) protected virtual void OnViewportChanged(ViewportChangedEventArgs e)
{ {
InvalidateVisual(); InvalidateVisual();
} }
public override void Render(DrawingContext drawingContext) public override void Render(DrawingContext drawingContext)
{
if (ParentMap != null)
{ {
if (ParentMap != null) var pathGeometry = new PathGeometry();
var labels = new List<Label>();
var pen = new Pen
{ {
var pathGeometry = new PathGeometry(); Brush = Foreground,
var labels = new List<Label>(); Thickness = StrokeThickness,
var pen = new Pen };
DrawGrid(pathGeometry.Figures, labels);
drawingContext.DrawGeometry(null, pen, pathGeometry);
if (labels.Count > 0)
{
var typeface = new Typeface(FontFamily, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal);
foreach (var label in labels)
{ {
Brush = Foreground, var text = new FormattedText(label.Text,
Thickness = StrokeThickness, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, FontSize, Foreground);
}; var x = label.X +
label.HorizontalAlignment switch
{
HorizontalAlignment.Left => 2d,
HorizontalAlignment.Right => -text.Width - 2d,
_ => -text.Width / 2d
};
var y = label.Y +
label.VerticalAlignment switch
{
VerticalAlignment.Top => 0,
VerticalAlignment.Bottom => -text.Height,
_ => -text.Height / 2d
};
DrawGrid(pathGeometry.Figures, labels); if (label.Rotation != 0d)
drawingContext.DrawGeometry(null, pen, pathGeometry);
if (labels.Count > 0)
{
var typeface = new Typeface(FontFamily, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal);
foreach (var label in labels)
{ {
var text = new FormattedText(label.Text, var transform = Avalonia.Matrix.CreateRotation(
CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, FontSize, Foreground); label.Rotation * Math.PI / 180d, new Point(label.X, label.Y));
var x = label.X +
label.HorizontalAlignment switch
{
HorizontalAlignment.Left => 2d,
HorizontalAlignment.Right => -text.Width - 2d,
_ => -text.Width / 2d
};
var y = label.Y +
label.VerticalAlignment switch
{
VerticalAlignment.Top => 0,
VerticalAlignment.Bottom => -text.Height,
_ => -text.Height / 2d
};
if (label.Rotation != 0d) using var pushedState = drawingContext.PushTransform(transform);
{
var transform = Avalonia.Matrix.CreateRotation(
label.Rotation * Math.PI / 180d, new Point(label.X, label.Y));
using var pushedState = drawingContext.PushTransform(transform); drawingContext.DrawText(text, new Point(x, y));
}
drawingContext.DrawText(text, new Point(x, y)); else
} {
else drawingContext.DrawText(text, new Point(x, y));
{
drawingContext.DrawText(text, new Point(x, y));
}
} }
} }
} }
} }
}
private static PolyLineSegment CreatePolyLineSegment(IEnumerable<Point> points) private static PolyLineSegment CreatePolyLineSegment(IEnumerable<Point> points)
{ {
return new PolyLineSegment(points); return new PolyLineSegment(points);
}
} }
} }

View file

@ -2,29 +2,28 @@
using Avalonia.Styling; using Avalonia.Styling;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace MapControl namespace MapControl;
{
public partial class MapImageLayer
{
private void FadeOver()
{
var fadeInAnimation = new Animation
{
FillMode = FillMode.Forward,
Duration = MapBase.ImageFadeDuration,
Children =
{
new KeyFrame
{
KeyTime = MapBase.ImageFadeDuration,
Setters = { new Setter(OpacityProperty, 1d) }
}
}
};
_ = fadeInAnimation.RunAsync(Children[1]).ContinueWith( public partial class MapImageLayer
_ => Children[0].Opacity = 0d, {
TaskScheduler.FromCurrentSynchronizationContext()); private void FadeOver()
} {
var fadeInAnimation = new Animation
{
FillMode = FillMode.Forward,
Duration = MapBase.ImageFadeDuration,
Children =
{
new KeyFrame
{
KeyTime = MapBase.ImageFadeDuration,
Setters = { new Setter(OpacityProperty, 1d) }
}
}
};
_ = fadeInAnimation.RunAsync(Children[1]).ContinueWith(
_ => Children[0].Opacity = 0d,
TaskScheduler.FromCurrentSynchronizationContext());
} }
} }

View file

@ -1,31 +1,30 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
namespace MapControl namespace MapControl;
public partial class MapItem
{ {
public partial class MapItem protected override void OnPointerPressed(PointerPressedEventArgs e)
{ {
protected override void OnPointerPressed(PointerPressedEventArgs e) if (e.Pointer.Type != PointerType.Mouse &&
ItemsControl.ItemsControlFromItemContainer(this) is MapItemsControl mapItemsControl)
{ {
if (e.Pointer.Type != PointerType.Mouse && mapItemsControl.UpdateSelectionFromEvent(this, e);
ItemsControl.ItemsControlFromItemContainer(this) is MapItemsControl mapItemsControl)
{
mapItemsControl.UpdateSelectionFromEvent(this, e);
}
e.Handled = true;
} }
protected override void OnPointerReleased(PointerReleasedEventArgs e) e.Handled = true;
{ }
if (e.Pointer.Type == PointerType.Mouse &&
e.InitialPressMouseButton == MouseButton.Left &&
ItemsControl.ItemsControlFromItemContainer(this) is MapItemsControl mapItemsControl)
{
mapItemsControl.UpdateSelectionFromEvent(this, e);
}
e.Handled = true; protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
if (e.Pointer.Type == PointerType.Mouse &&
e.InitialPressMouseButton == MouseButton.Left &&
ItemsControl.ItemsControlFromItemContainer(this) is MapItemsControl mapItemsControl)
{
mapItemsControl.UpdateSelectionFromEvent(this, e);
} }
e.Handled = true;
} }
} }

View file

@ -6,70 +6,69 @@ using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Media; using Avalonia.Media;
namespace MapControl namespace MapControl;
public partial class MapItemsControl
{ {
public partial class MapItemsControl static MapItemsControl()
{ {
static MapItemsControl() TemplateProperty.OverrideDefaultValue<MapItemsControl>(
{ new FuncControlTemplate<MapItemsControl>(
TemplateProperty.OverrideDefaultValue<MapItemsControl>( (itemsControl, namescope) => new ItemsPresenter { ItemsPanel = itemsControl.ItemsPanel }));
new FuncControlTemplate<MapItemsControl>(
(itemsControl, namescope) => new ItemsPresenter { ItemsPanel = itemsControl.ItemsPanel })); ItemsPanelProperty.OverrideDefaultValue<MapItemsControl>(
new FuncTemplate<Panel>(() => new MapPanel()));
ItemsPanelProperty.OverrideDefaultValue<MapItemsControl>( }
new FuncTemplate<Panel>(() => new MapPanel()));
} public void SelectItemsInGeometry(Geometry geometry)
{
public void SelectItemsInGeometry(Geometry geometry) SelectItemsByPosition(geometry.FillContains);
{ }
SelectItemsByPosition(geometry.FillContains);
} public new MapItem ContainerFromItem(object item)
{
public new MapItem ContainerFromItem(object item) return (MapItem)base.ContainerFromItem(item);
{ }
return (MapItem)base.ContainerFromItem(item);
} protected override bool NeedsContainerOverride(object item, int index, out object recycleKey)
{
protected override bool NeedsContainerOverride(object item, int index, out object recycleKey) recycleKey = null;
{
recycleKey = null; return item is not MapItem;
}
return item is not MapItem;
} protected override Control CreateContainerForItemOverride(object item, int index, object recycleKey)
{
protected override Control CreateContainerForItemOverride(object item, int index, object recycleKey) return new MapItem();
{ }
return new MapItem();
} protected override void PrepareContainerForItemOverride(Control container, object item, int index)
{
protected override void PrepareContainerForItemOverride(Control container, object item, int index) base.PrepareContainerForItemOverride(container, item, index);
{ PrepareContainer((MapItem)container, item);
base.PrepareContainerForItemOverride(container, item, index); }
PrepareContainer((MapItem)container, item);
} protected override void ClearContainerForItemOverride(Control container)
{
protected override void ClearContainerForItemOverride(Control container) base.ClearContainerForItemOverride(container);
{ ClearContainer((MapItem)container);
base.ClearContainerForItemOverride(container); }
ClearContainer((MapItem)container);
} protected override bool ShouldTriggerSelection(Visual selectable, PointerEventArgs eventArgs)
{
protected override bool ShouldTriggerSelection(Visual selectable, PointerEventArgs eventArgs) return true;
}
public override bool UpdateSelectionFromEvent(UIElement container, RoutedEventArgs eventArgs)
{
if (SelectionMode == SelectionMode.Multiple &&
eventArgs is PointerEventArgs e &&
e.KeyModifiers.HasFlag(KeyModifiers.Shift))
{ {
SelectItemsInRange((MapItem)container);
return true; return true;
} }
public override bool UpdateSelectionFromEvent(UIElement container, RoutedEventArgs eventArgs) return base.UpdateSelectionFromEvent(container, eventArgs);
{
if (SelectionMode == SelectionMode.Multiple &&
eventArgs is PointerEventArgs e &&
e.KeyModifiers.HasFlag(KeyModifiers.Shift))
{
SelectItemsInRange((MapItem)container);
return true;
}
return base.UpdateSelectionFromEvent(container, eventArgs);
}
} }
} }

View file

@ -1,29 +1,28 @@
using Avalonia; using Avalonia;
namespace MapControl namespace MapControl;
public partial class MapPanel
{ {
public partial class MapPanel public static readonly AttachedProperty<bool> AutoCollapseProperty =
DependencyPropertyHelper.RegisterAttached<bool>("AutoCollapse", typeof(MapPanel));
public static readonly AttachedProperty<Location> LocationProperty =
DependencyPropertyHelper.RegisterAttached<Location>("Location", typeof(MapPanel));
public static readonly AttachedProperty<BoundingBox> BoundingBoxProperty =
DependencyPropertyHelper.RegisterAttached<BoundingBox>("BoundingBox", typeof(MapPanel));
public static readonly AttachedProperty<Rect?> MapRectProperty =
DependencyPropertyHelper.RegisterAttached<Rect?>("MapRect", typeof(MapPanel));
static MapPanel()
{ {
public static readonly AttachedProperty<bool> AutoCollapseProperty = AffectsParentArrange<MapPanel>(LocationProperty, BoundingBoxProperty, MapRectProperty);
DependencyPropertyHelper.RegisterAttached<bool>("AutoCollapse", typeof(MapPanel)); }
public static readonly AttachedProperty<Location> LocationProperty = public static MapBase GetParentMap(FrameworkElement element)
DependencyPropertyHelper.RegisterAttached<Location>("Location", typeof(MapPanel)); {
return (MapBase)element.GetValue(ParentMapProperty);
public static readonly AttachedProperty<BoundingBox> BoundingBoxProperty =
DependencyPropertyHelper.RegisterAttached<BoundingBox>("BoundingBox", typeof(MapPanel));
public static readonly AttachedProperty<Rect?> MapRectProperty =
DependencyPropertyHelper.RegisterAttached<Rect?>("MapRect", typeof(MapPanel));
static MapPanel()
{
AffectsParentArrange<MapPanel>(LocationProperty, BoundingBoxProperty, MapRectProperty);
}
public static MapBase GetParentMap(FrameworkElement element)
{
return (MapBase)element.GetValue(ParentMapProperty);
}
} }
} }

View file

@ -2,20 +2,19 @@
using Avalonia.Controls.Shapes; using Avalonia.Controls.Shapes;
using Avalonia.Media; using Avalonia.Media;
namespace MapControl namespace MapControl;
public partial class MapPath : Shape
{ {
public partial class MapPath : Shape public static readonly StyledProperty<Geometry> DataProperty =
DependencyPropertyHelper.AddOwner<MapPath, Geometry>(Path.DataProperty,
(path, oldValue, newValue) => path.UpdateData());
public Geometry Data
{ {
public static readonly StyledProperty<Geometry> DataProperty = get => GetValue(DataProperty);
DependencyPropertyHelper.AddOwner<MapPath, Geometry>(Path.DataProperty, set => SetValue(DataProperty, value);
(path, oldValue, newValue) => path.UpdateData());
public Geometry Data
{
get => GetValue(DataProperty);
set => SetValue(DataProperty, value);
}
protected override Geometry CreateDefiningGeometry() => Data;
} }
protected override Geometry CreateDefiningGeometry() => Data;
} }

View file

@ -4,89 +4,88 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
namespace MapControl namespace MapControl;
public partial class MapPolypoint : MapPath
{ {
public partial class MapPolypoint : MapPath protected void UpdateData(IEnumerable<Location> locations, bool closed)
{ {
protected void UpdateData(IEnumerable<Location> locations, bool closed) var figures = new PathFigures();
if (ParentMap != null && locations != null)
{ {
var figures = new PathFigures(); var longitudeOffset = GetLongitudeOffset(locations);
if (ParentMap != null && locations != null) AddPolylinePoints(figures, locations, longitudeOffset, closed);
{
var longitudeOffset = GetLongitudeOffset(locations);
AddPolylinePoints(figures, locations, longitudeOffset, closed);
}
SetPathFigures(figures);
} }
protected void UpdateData(IEnumerable<IEnumerable<Location>> polygons) SetPathFigures(figures);
}
protected void UpdateData(IEnumerable<IEnumerable<Location>> polygons)
{
var figures = new PathFigures();
if (ParentMap != null && polygons != null)
{ {
var figures = new PathFigures(); var longitudeOffset = GetLongitudeOffset(polygons.FirstOrDefault());
if (ParentMap != null && polygons != null) foreach (var locations in polygons)
{ {
var longitudeOffset = GetLongitudeOffset(polygons.FirstOrDefault()); AddPolylinePoints(figures, locations, longitudeOffset, true);
foreach (var locations in polygons)
{
AddPolylinePoints(figures, locations, longitudeOffset, true);
}
}
SetPathFigures(figures);
}
private void AddPolylinePoints(PathFigures figures, IEnumerable<Location> locations, double longitudeOffset, bool closed)
{
var points = locations.Select(location => LocationToView(location, longitudeOffset));
if (points.Any())
{
var start = points.First();
var polyline = new PolyLineSegment(points.Skip(1));
var minX = start.X;
var maxX = start.X;
var minY = start.Y;
var maxY = start.Y;
foreach (var point in polyline.Points)
{
minX = Math.Min(minX, point.X);
maxX = Math.Max(maxX, point.X);
minY = Math.Min(minY, point.Y);
maxY = Math.Max(maxY, point.Y);
}
if (maxX >= 0d && minX <= ParentMap.ActualWidth &&
maxY >= 0d && minY <= ParentMap.ActualHeight)
{
var figure = new PathFigure
{
StartPoint = start,
IsClosed = closed,
IsFilled = true
};
figure.Segments.Add(polyline);
figures.Add(figure);
}
} }
} }
private void SetPathFigures(PathFigures figures) SetPathFigures(figures);
}
private void AddPolylinePoints(PathFigures figures, IEnumerable<Location> locations, double longitudeOffset, bool closed)
{
var points = locations.Select(location => LocationToView(location, longitudeOffset));
if (points.Any())
{ {
if (figures.Count == 0) var start = points.First();
var polyline = new PolyLineSegment(points.Skip(1));
var minX = start.X;
var maxX = start.X;
var minY = start.Y;
var maxY = start.Y;
foreach (var point in polyline.Points)
{ {
// Avalonia Shape seems to ignore PathGeometry with empty Figures collection. minX = Math.Min(minX, point.X);
// maxX = Math.Max(maxX, point.X);
figures.Add(new PathFigure { StartPoint = new Point(-1000, -1000) }); minY = Math.Min(minY, point.Y);
maxY = Math.Max(maxY, point.Y);
} }
((PathGeometry)Data).Figures = figures; if (maxX >= 0d && minX <= ParentMap.ActualWidth &&
InvalidateGeometry(); maxY >= 0d && minY <= ParentMap.ActualHeight)
{
var figure = new PathFigure
{
StartPoint = start,
IsClosed = closed,
IsFilled = true
};
figure.Segments.Add(polyline);
figures.Add(figure);
}
} }
} }
private void SetPathFigures(PathFigures figures)
{
if (figures.Count == 0)
{
// Avalonia Shape seems to ignore PathGeometry with empty Figures collection.
//
figures.Add(new PathFigure { StartPoint = new Point(-1000, -1000) });
}
((PathGeometry)Data).Figures = figures;
InvalidateGeometry();
}
} }

View file

@ -4,100 +4,99 @@ using Avalonia.Media;
using System; using System;
using Brush = Avalonia.Media.IBrush; using Brush = Avalonia.Media.IBrush;
namespace MapControl namespace MapControl;
public partial class PushpinBorder : Decorator
{ {
public partial class PushpinBorder : Decorator public static readonly StyledProperty<CornerRadius> CornerRadiusProperty =
DependencyPropertyHelper.Register<PushpinBorder, CornerRadius>(nameof(CornerRadius), new CornerRadius());
public static readonly StyledProperty<Size> ArrowSizeProperty =
DependencyPropertyHelper.Register<PushpinBorder, Size>(nameof(ArrowSize), new Size(10d, 20d));
public static readonly StyledProperty<double> BorderWidthProperty =
DependencyPropertyHelper.Register<PushpinBorder, double>(nameof(BorderWidth));
public static readonly StyledProperty<Brush> BackgroundProperty =
DependencyPropertyHelper.Register<PushpinBorder, Brush>(nameof(Background));
public static readonly StyledProperty<Brush> BorderBrushProperty =
DependencyPropertyHelper.Register<PushpinBorder, Brush>(nameof(BorderBrush));
static PushpinBorder()
{ {
public static readonly StyledProperty<CornerRadius> CornerRadiusProperty = AffectsMeasure<PushpinBorder>(ArrowSizeProperty, BorderWidthProperty, CornerRadiusProperty);
DependencyPropertyHelper.Register<PushpinBorder, CornerRadius>(nameof(CornerRadius), new CornerRadius()); AffectsRender<PushpinBorder>(BackgroundProperty, BorderBrushProperty);
}
public static readonly StyledProperty<Size> ArrowSizeProperty = public double ActualWidth => Bounds.Width;
DependencyPropertyHelper.Register<PushpinBorder, Size>(nameof(ArrowSize), new Size(10d, 20d)); public double ActualHeight => Bounds.Height;
public static readonly StyledProperty<double> BorderWidthProperty = public CornerRadius CornerRadius
DependencyPropertyHelper.Register<PushpinBorder, double>(nameof(BorderWidth)); {
get => GetValue(CornerRadiusProperty);
set => SetValue(CornerRadiusProperty, value);
}
public static readonly StyledProperty<Brush> BackgroundProperty = public Brush Background
DependencyPropertyHelper.Register<PushpinBorder, Brush>(nameof(Background)); {
get => GetValue(BackgroundProperty);
set => SetValue(BackgroundProperty, value);
}
public static readonly StyledProperty<Brush> BorderBrushProperty = public Brush BorderBrush
DependencyPropertyHelper.Register<PushpinBorder, Brush>(nameof(BorderBrush)); {
get => GetValue(BorderBrushProperty);
set => SetValue(BorderBrushProperty, value);
}
static PushpinBorder() protected override Size MeasureOverride(Size constraint)
{
var width = 2d * BorderWidth + Padding.Left + Padding.Right;
var height = 2d * BorderWidth + Padding.Top + Padding.Bottom;
if (Child != null)
{ {
AffectsMeasure<PushpinBorder>(ArrowSizeProperty, BorderWidthProperty, CornerRadiusProperty); Child.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
AffectsRender<PushpinBorder>(BackgroundProperty, BorderBrushProperty); width += Child.DesiredSize.Width;
height += Child.DesiredSize.Height;
} }
public double ActualWidth => Bounds.Width; var minWidth = BorderWidth + Math.Max(
public double ActualHeight => Bounds.Height; CornerRadius.TopLeft + CornerRadius.TopRight,
CornerRadius.BottomLeft + CornerRadius.BottomRight + ArrowSize.Width);
public CornerRadius CornerRadius var minHeight = BorderWidth + Math.Max(
CornerRadius.TopLeft + CornerRadius.BottomLeft,
CornerRadius.TopRight + CornerRadius.BottomRight);
return new Size(
Math.Max(width, minWidth),
Math.Max(height, minHeight) + ArrowSize.Height);
}
protected override Size ArrangeOverride(Size size)
{
Child?.Arrange(new Rect(
BorderWidth + Padding.Left,
BorderWidth + Padding.Top,
Child.DesiredSize.Width,
Child.DesiredSize.Height));
return DesiredSize;
}
public override void Render(DrawingContext drawingContext)
{
var pen = new Pen
{ {
get => GetValue(CornerRadiusProperty); Brush = BorderBrush,
set => SetValue(CornerRadiusProperty, value); Thickness = BorderWidth,
} LineJoin = PenLineJoin.Round
};
public Brush Background drawingContext.DrawGeometry(Background, pen, BuildGeometry());
{
get => GetValue(BackgroundProperty);
set => SetValue(BackgroundProperty, value);
}
public Brush BorderBrush base.Render(drawingContext);
{
get => GetValue(BorderBrushProperty);
set => SetValue(BorderBrushProperty, value);
}
protected override Size MeasureOverride(Size constraint)
{
var width = 2d * BorderWidth + Padding.Left + Padding.Right;
var height = 2d * BorderWidth + Padding.Top + Padding.Bottom;
if (Child != null)
{
Child.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
width += Child.DesiredSize.Width;
height += Child.DesiredSize.Height;
}
var minWidth = BorderWidth + Math.Max(
CornerRadius.TopLeft + CornerRadius.TopRight,
CornerRadius.BottomLeft + CornerRadius.BottomRight + ArrowSize.Width);
var minHeight = BorderWidth + Math.Max(
CornerRadius.TopLeft + CornerRadius.BottomLeft,
CornerRadius.TopRight + CornerRadius.BottomRight);
return new Size(
Math.Max(width, minWidth),
Math.Max(height, minHeight) + ArrowSize.Height);
}
protected override Size ArrangeOverride(Size size)
{
Child?.Arrange(new Rect(
BorderWidth + Padding.Left,
BorderWidth + Padding.Top,
Child.DesiredSize.Width,
Child.DesiredSize.Height));
return DesiredSize;
}
public override void Render(DrawingContext drawingContext)
{
var pen = new Pen
{
Brush = BorderBrush,
Thickness = BorderWidth,
LineJoin = PenLineJoin.Round
};
drawingContext.DrawGeometry(Background, pen, BuildGeometry());
base.Render(drawingContext);
}
} }
} }

View file

@ -1,50 +1,49 @@
using System; using System;
using System.Globalization; using System.Globalization;
namespace MapControl namespace MapControl;
{
/// <summary> /// <summary>
/// A geographic bounding box with south and north latitude and west and east longitude values in degrees. /// A geographic bounding box with south and north latitude and west and east longitude values in degrees.
/// </summary> /// </summary>
#if UWP || WINUI #if UWP || WINUI
[Windows.Foundation.Metadata.CreateFromString(MethodName = "Parse")] [Windows.Foundation.Metadata.CreateFromString(MethodName = "Parse")]
#else #else
[System.ComponentModel.TypeConverter(typeof(BoundingBoxConverter))] [System.ComponentModel.TypeConverter(typeof(BoundingBoxConverter))]
#endif #endif
public class BoundingBox(double latitude1, double longitude1, double latitude2, double longitude2) public class BoundingBox(double latitude1, double longitude1, double latitude2, double longitude2)
{
public double South { get; } = Math.Min(Math.Max(Math.Min(latitude1, latitude2), -90d), 90d);
public double North { get; } = Math.Min(Math.Max(Math.Max(latitude1, latitude2), -90d), 90d);
public double West { get; } = Math.Min(longitude1, longitude2);
public double East { get; } = Math.Max(longitude1, longitude2);
public override string ToString()
{ {
public double South { get; } = Math.Min(Math.Max(Math.Min(latitude1, latitude2), -90d), 90d); return string.Format(CultureInfo.InvariantCulture, "{0},{1},{2},{3}", South, West, North, East);
public double North { get; } = Math.Min(Math.Max(Math.Max(latitude1, latitude2), -90d), 90d); }
public double West { get; } = Math.Min(longitude1, longitude2);
public double East { get; } = Math.Max(longitude1, longitude2);
public override string ToString() /// <summary>
/// Creates a BoundingBox instance from a string containing a comma-separated sequence of four floating point numbers.
/// </summary>
public static BoundingBox Parse(string boundingBox)
{
string[] values = null;
if (!string.IsNullOrEmpty(boundingBox))
{ {
return string.Format(CultureInfo.InvariantCulture, "{0},{1},{2},{3}", South, West, North, East); values = boundingBox.Split(',');
} }
/// <summary> if (values == null || values.Length != 4 && values.Length != 5)
/// Creates a BoundingBox instance from a string containing a comma-separated sequence of four floating point numbers.
/// </summary>
public static BoundingBox Parse(string boundingBox)
{ {
string[] values = null; throw new FormatException($"{nameof(BoundingBox)} string must contain a comma-separated sequence of four floating point numbers.");
if (!string.IsNullOrEmpty(boundingBox))
{
values = boundingBox.Split(',');
}
if (values == null || values.Length != 4 && values.Length != 5)
{
throw new FormatException($"{nameof(BoundingBox)} string must contain a comma-separated sequence of four floating point numbers.");
}
return new BoundingBox(
double.Parse(values[0], NumberStyles.Float, CultureInfo.InvariantCulture),
double.Parse(values[1], NumberStyles.Float, CultureInfo.InvariantCulture),
double.Parse(values[2], NumberStyles.Float, CultureInfo.InvariantCulture),
double.Parse(values[3], NumberStyles.Float, CultureInfo.InvariantCulture));
} }
return new BoundingBox(
double.Parse(values[0], NumberStyles.Float, CultureInfo.InvariantCulture),
double.Parse(values[1], NumberStyles.Float, CultureInfo.InvariantCulture),
double.Parse(values[2], NumberStyles.Float, CultureInfo.InvariantCulture),
double.Parse(values[3], NumberStyles.Float, CultureInfo.InvariantCulture));
} }
} }

View file

@ -6,45 +6,44 @@ using System.Windows.Media;
using Avalonia; using Avalonia;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Equirectangular Projection - EPSG:4326.
/// Equidistant cylindrical projection with zero standard parallel and central meridian.
/// See "Map Projections - A Working Manual" (https://pubs.usgs.gov/publication/pp1395), p.90-91.
/// </summary>
public class EquirectangularProjection : MapProjection
{ {
/// <summary> public const string DefaultCrsId = "EPSG:4326";
/// Equirectangular Projection - EPSG:4326.
/// Equidistant cylindrical projection with zero standard parallel and central meridian. public EquirectangularProjection() // parameterless constructor for XAML
/// See "Map Projections - A Working Manual" (https://pubs.usgs.gov/publication/pp1395), p.90-91. : this(DefaultCrsId)
/// </summary>
public class EquirectangularProjection : MapProjection
{ {
public const string DefaultCrsId = "EPSG:4326"; }
public EquirectangularProjection() // parameterless constructor for XAML public EquirectangularProjection(string crsId)
: this(DefaultCrsId) {
{ IsNormalCylindrical = true;
} CrsId = crsId;
}
public EquirectangularProjection(string crsId) public override Matrix RelativeTransform(double latitude, double longitude)
{ {
IsNormalCylindrical = true; return new Matrix(1d / Math.Cos(latitude * Math.PI / 180d), 0d, 0d, 1d, 0d, 0d);
CrsId = crsId; }
}
public override Matrix RelativeTransform(double latitude, double longitude) public override Point LocationToMap(double latitude, double longitude)
{ {
return new Matrix(1d / Math.Cos(latitude * Math.PI / 180d), 0d, 0d, 1d, 0d, 0d); return new Point(
} EquatorialRadius * Math.PI / 180d * longitude,
EquatorialRadius * Math.PI / 180d * latitude);
}
public override Point LocationToMap(double latitude, double longitude) public override Location MapToLocation(double x, double y)
{ {
return new Point( return new Location(
EquatorialRadius * Math.PI / 180d * longitude, y / EquatorialRadius * 180d / Math.PI,
EquatorialRadius * Math.PI / 180d * latitude); x / EquatorialRadius * 180d / Math.PI);
}
public override Location MapToLocation(double x, double y)
{
return new Location(
y / EquatorialRadius * 180d / Math.PI,
x / EquatorialRadius * 180d / Math.PI);
}
} }
} }

View file

@ -1,16 +1,15 @@
using System.IO; using System.IO;
namespace MapControl namespace MapControl;
public static class FilePath
{ {
public static class FilePath public static string GetFullPath(string path)
{ {
public static string GetFullPath(string path)
{
#if NETFRAMEWORK #if NETFRAMEWORK
return Path.GetFullPath(path); return Path.GetFullPath(path);
#else #else
return Path.GetFullPath(path, System.AppDomain.CurrentDomain.BaseDirectory); return Path.GetFullPath(path, System.AppDomain.CurrentDomain.BaseDirectory);
#endif #endif
}
} }
} }

View file

@ -29,183 +29,182 @@ using Shape = Avalonia.Controls.Shapes.Shape;
using BitmapSource = Avalonia.Media.Imaging.Bitmap; using BitmapSource = Avalonia.Media.Imaging.Bitmap;
#endif #endif
namespace MapControl namespace MapControl;
public static partial class GeoImage
{ {
public static partial class GeoImage private class GeoBitmap
{ {
private class GeoBitmap public GeoBitmap(BitmapSource bitmap, Matrix transform, MapProjection projection)
{ {
public GeoBitmap(BitmapSource bitmap, Matrix transform, MapProjection projection) var p1 = transform.Transform(new Point());
{
var p1 = transform.Transform(new Point());
#if AVALONIA #if AVALONIA
var p2 = transform.Transform(new Point(bitmap.PixelSize.Width, bitmap.PixelSize.Height)); var p2 = transform.Transform(new Point(bitmap.PixelSize.Width, bitmap.PixelSize.Height));
#else #else
var p2 = transform.Transform(new Point(bitmap.PixelWidth, bitmap.PixelHeight)); var p2 = transform.Transform(new Point(bitmap.PixelWidth, bitmap.PixelHeight));
#endif #endif
BitmapSource = bitmap; BitmapSource = bitmap;
if (projection != null) if (projection != null)
{
var sw = projection.MapToLocation(p1);
var ne = projection.MapToLocation(p2);
BoundingBox = new BoundingBox(sw.Latitude, sw.Longitude, ne.Latitude, ne.Longitude);
}
else
{
BoundingBox = new BoundingBox(p1.Y, p1.X, p2.Y, p2.X);
}
}
public BitmapSource BitmapSource { get; }
public BoundingBox BoundingBox { get; }
}
private const ushort ProjectedCRSGeoKey = 3072;
private const ushort GeoKeyDirectoryTag = 34735;
private const ushort ModelPixelScaleTag = 33550;
private const ushort ModelTiePointTag = 33922;
private const ushort ModelTransformationTag = 34264;
private const ushort NoDataTag = 42113;
private static string QueryString(ushort tag) => $"/ifd/{{ushort={tag}}}";
private static ILogger Logger => field ??= ImageLoader.LoggerFactory?.CreateLogger(typeof(GeoImage));
public static readonly DependencyProperty SourcePathProperty =
DependencyPropertyHelper.RegisterAttached<string>("SourcePath", typeof(GeoImage), null,
async (element, oldValue, newValue) => await LoadGeoImage(element, newValue));
public static string GetSourcePath(FrameworkElement image)
{
return (string)image.GetValue(SourcePathProperty);
}
public static void SetSourcePath(FrameworkElement image, string value)
{
image.SetValue(SourcePathProperty, value);
}
public static async Task<Image> CreateAsync(string sourcePath)
{
var image = new Image();
await LoadGeoImage(image, sourcePath);
return image;
}
public static Task LoadGeoImageAsync(this Image image, string sourcePath)
{
return LoadGeoImage(image, sourcePath);
}
public static Task LoadGeoImageAsync(this Shape shape, string sourcePath)
{
return LoadGeoImage(shape, sourcePath);
}
private static async Task LoadGeoImage(FrameworkElement element, string sourcePath)
{
if (!string.IsNullOrEmpty(sourcePath))
{ {
try var sw = projection.MapToLocation(p1);
{ var ne = projection.MapToLocation(p2);
var geoBitmap = await LoadGeoBitmap(sourcePath); BoundingBox = new BoundingBox(sw.Latitude, sw.Longitude, ne.Latitude, ne.Longitude);
}
else
{
BoundingBox = new BoundingBox(p1.Y, p1.X, p2.Y, p2.X);
}
}
if (element is Image image) public BitmapSource BitmapSource { get; }
public BoundingBox BoundingBox { get; }
}
private const ushort ProjectedCRSGeoKey = 3072;
private const ushort GeoKeyDirectoryTag = 34735;
private const ushort ModelPixelScaleTag = 33550;
private const ushort ModelTiePointTag = 33922;
private const ushort ModelTransformationTag = 34264;
private const ushort NoDataTag = 42113;
private static string QueryString(ushort tag) => $"/ifd/{{ushort={tag}}}";
private static ILogger Logger => field ??= ImageLoader.LoggerFactory?.CreateLogger(typeof(GeoImage));
public static readonly DependencyProperty SourcePathProperty =
DependencyPropertyHelper.RegisterAttached<string>("SourcePath", typeof(GeoImage), null,
async (element, oldValue, newValue) => await LoadGeoImage(element, newValue));
public static string GetSourcePath(FrameworkElement image)
{
return (string)image.GetValue(SourcePathProperty);
}
public static void SetSourcePath(FrameworkElement image, string value)
{
image.SetValue(SourcePathProperty, value);
}
public static async Task<Image> CreateAsync(string sourcePath)
{
var image = new Image();
await LoadGeoImage(image, sourcePath);
return image;
}
public static Task LoadGeoImageAsync(this Image image, string sourcePath)
{
return LoadGeoImage(image, sourcePath);
}
public static Task LoadGeoImageAsync(this Shape shape, string sourcePath)
{
return LoadGeoImage(shape, sourcePath);
}
private static async Task LoadGeoImage(FrameworkElement element, string sourcePath)
{
if (!string.IsNullOrEmpty(sourcePath))
{
try
{
var geoBitmap = await LoadGeoBitmap(sourcePath);
if (element is Image image)
{
image.Stretch = Stretch.Fill;
image.Source = geoBitmap.BitmapSource;
}
else if (element is Shape shape)
{
shape.Stretch = Stretch.Fill;
shape.Fill = new ImageBrush
{ {
image.Stretch = Stretch.Fill; Stretch = Stretch.Fill,
image.Source = geoBitmap.BitmapSource;
}
else if (element is Shape shape)
{
shape.Stretch = Stretch.Fill;
shape.Fill = new ImageBrush
{
Stretch = Stretch.Fill,
#if AVALONIA #if AVALONIA
Source = geoBitmap.BitmapSource Source = geoBitmap.BitmapSource
#else #else
ImageSource = geoBitmap.BitmapSource ImageSource = geoBitmap.BitmapSource
#endif #endif
}; };
}
MapPanel.SetBoundingBox(element, geoBitmap.BoundingBox);
}
catch (Exception ex)
{
Logger?.LogError(ex, "Failed loading from {path}", sourcePath);
} }
MapPanel.SetBoundingBox(element, geoBitmap.BoundingBox);
} }
} catch (Exception ex)
private static async Task<GeoBitmap> LoadGeoBitmap(string sourcePath)
{
var ext = System.IO.Path.GetExtension(sourcePath);
if (ext.Length >= 4)
{ {
var dir = Path.GetDirectoryName(sourcePath); Logger?.LogError(ex, "Failed loading from {path}", sourcePath);
var file = Path.GetFileNameWithoutExtension(sourcePath);
var worldFilePath = Path.Combine(dir, file + ext.Remove(2, 1) + "w");
if (File.Exists(worldFilePath))
{
return new GeoBitmap(
(BitmapSource)await ImageLoader.LoadImageAsync(sourcePath),
await ReadWorldFileMatrix(worldFilePath),
null);
}
} }
return await LoadGeoTiff(sourcePath);
}
private static async Task<Matrix> ReadWorldFileMatrix(string worldFilePath)
{
using var fileStream = File.OpenRead(worldFilePath);
using var streamReader = new StreamReader(fileStream);
var parameters = new double[6];
var index = 0;
string line;
while (index < 6 &&
(line = await streamReader.ReadLineAsync()) != null &&
double.TryParse(line, NumberStyles.Float, CultureInfo.InvariantCulture, out double parameter))
{
parameters[index++] = parameter;
}
if (index != 6)
{
throw new ArgumentException($"Insufficient number of parameters in world file {worldFilePath}.");
}
return new Matrix( // https://en.wikipedia.org/wiki/World_file
parameters[0], // line 1: A
parameters[1], // line 2: D
parameters[2], // line 3: B
parameters[3], // line 4: E
parameters[4], // line 5: C
parameters[5]); // line 6: F
}
private static MapProjection GetProjection(short[] geoKeyDirectory)
{
for (var i = 4; i < geoKeyDirectory.Length - 3; i += 4)
{
if (geoKeyDirectory[i] == ProjectedCRSGeoKey && geoKeyDirectory[i + 1] == 0)
{
var epsgCode = geoKeyDirectory[i + 3];
return MapProjection.Parse($"EPSG:{epsgCode}");
}
}
return null;
} }
} }
private static async Task<GeoBitmap> LoadGeoBitmap(string sourcePath)
{
var ext = System.IO.Path.GetExtension(sourcePath);
if (ext.Length >= 4)
{
var dir = Path.GetDirectoryName(sourcePath);
var file = Path.GetFileNameWithoutExtension(sourcePath);
var worldFilePath = Path.Combine(dir, file + ext.Remove(2, 1) + "w");
if (File.Exists(worldFilePath))
{
return new GeoBitmap(
(BitmapSource)await ImageLoader.LoadImageAsync(sourcePath),
await ReadWorldFileMatrix(worldFilePath),
null);
}
}
return await LoadGeoTiff(sourcePath);
}
private static async Task<Matrix> ReadWorldFileMatrix(string worldFilePath)
{
using var fileStream = File.OpenRead(worldFilePath);
using var streamReader = new StreamReader(fileStream);
var parameters = new double[6];
var index = 0;
string line;
while (index < 6 &&
(line = await streamReader.ReadLineAsync()) != null &&
double.TryParse(line, NumberStyles.Float, CultureInfo.InvariantCulture, out double parameter))
{
parameters[index++] = parameter;
}
if (index != 6)
{
throw new ArgumentException($"Insufficient number of parameters in world file {worldFilePath}.");
}
return new Matrix( // https://en.wikipedia.org/wiki/World_file
parameters[0], // line 1: A
parameters[1], // line 2: D
parameters[2], // line 3: B
parameters[3], // line 4: E
parameters[4], // line 5: C
parameters[5]); // line 6: F
}
private static MapProjection GetProjection(short[] geoKeyDirectory)
{
for (var i = 4; i < geoKeyDirectory.Length - 3; i += 4)
{
if (geoKeyDirectory[i] == ProjectedCRSGeoKey && geoKeyDirectory[i + 1] == 0)
{
var epsgCode = geoKeyDirectory[i + 3];
return MapProjection.Parse($"EPSG:{epsgCode}");
}
}
return null;
}
} }

View file

@ -24,213 +24,212 @@ using Avalonia.Controls;
using Avalonia.Media; using Avalonia.Media;
#endif #endif
namespace MapControl namespace MapControl;
public partial class GroundOverlay : MapPanel
{ {
public partial class GroundOverlay : MapPanel private class ImageOverlay
{ {
private class ImageOverlay public ImageOverlay(string path, BoundingBox latLonBox, int zIndex)
{ {
public ImageOverlay(string path, BoundingBox latLonBox, int zIndex) ImagePath = path;
SetBoundingBox(Image, latLonBox);
Image.SetValue(Canvas.ZIndexProperty, zIndex);
}
public string ImagePath { get; }
public Image Image { get; } = new Image { Stretch = Stretch.Fill };
public async Task LoadImage(Uri docUri)
{
Image.Source = await ImageLoader.LoadImageAsync(new Uri(docUri, ImagePath));
}
public async Task LoadImage(ZipArchive archive)
{
var entry = archive.GetEntry(ImagePath);
if (entry != null)
{ {
ImagePath = path; using var memoryStream = new MemoryStream((int)entry.Length);
SetBoundingBox(Image, latLonBox);
Image.SetValue(Canvas.ZIndexProperty, zIndex);
}
public string ImagePath { get; } using (var zipStream = entry.Open())
public Image Image { get; } = new Image { Stretch = Stretch.Fill };
public async Task LoadImage(Uri docUri)
{
Image.Source = await ImageLoader.LoadImageAsync(new Uri(docUri, ImagePath));
}
public async Task LoadImage(ZipArchive archive)
{
var entry = archive.GetEntry(ImagePath);
if (entry != null)
{ {
using var memoryStream = new MemoryStream((int)entry.Length); zipStream.CopyTo(memoryStream); // can't use CopyToAsync with ZipArchive
using (var zipStream = entry.Open())
{
zipStream.CopyTo(memoryStream); // can't use CopyToAsync with ZipArchive
}
memoryStream.Seek(0, SeekOrigin.Begin);
Image.Source = await ImageLoader.LoadImageAsync(memoryStream);
} }
memoryStream.Seek(0, SeekOrigin.Begin);
Image.Source = await ImageLoader.LoadImageAsync(memoryStream);
} }
} }
}
private static ILogger Logger => field ??= ImageLoader.LoggerFactory?.CreateLogger(typeof(GroundOverlay));
private static ILogger Logger => field ??= ImageLoader.LoggerFactory?.CreateLogger(typeof(GroundOverlay));
public static readonly DependencyProperty SourcePathProperty =
DependencyPropertyHelper.Register<GroundOverlay, string>(nameof(SourcePath), null, public static readonly DependencyProperty SourcePathProperty =
async (groundOverlay, oldValue, newValue) => await groundOverlay.LoadAsync(newValue)); DependencyPropertyHelper.Register<GroundOverlay, string>(nameof(SourcePath), null,
async (groundOverlay, oldValue, newValue) => await groundOverlay.LoadAsync(newValue));
public string SourcePath
{ public string SourcePath
get => (string)GetValue(SourcePathProperty); {
set => SetValue(SourcePathProperty, value); get => (string)GetValue(SourcePathProperty);
} set => SetValue(SourcePathProperty, value);
}
public static async Task<GroundOverlay> CreateAsync(string sourcePath)
{ public static async Task<GroundOverlay> CreateAsync(string sourcePath)
var groundOverlay = new GroundOverlay(); {
var groundOverlay = new GroundOverlay();
await groundOverlay.LoadAsync(sourcePath);
await groundOverlay.LoadAsync(sourcePath);
return groundOverlay;
} return groundOverlay;
}
public async Task LoadAsync(string sourcePath)
{ public async Task LoadAsync(string sourcePath)
List<ImageOverlay> imageOverlays = null; {
List<ImageOverlay> imageOverlays = null;
if (!string.IsNullOrEmpty(sourcePath))
{ if (!string.IsNullOrEmpty(sourcePath))
try {
{ try
var ext = Path.GetExtension(sourcePath).ToLower(); {
var ext = Path.GetExtension(sourcePath).ToLower();
if (ext == ".kmz")
{ if (ext == ".kmz")
imageOverlays = await LoadImageOverlaysFromArchive(sourcePath); {
} imageOverlays = await LoadImageOverlaysFromArchive(sourcePath);
else if (ext == ".kml") }
{ else if (ext == ".kml")
imageOverlays = await LoadImageOverlaysFromFile(sourcePath); {
} imageOverlays = await LoadImageOverlaysFromFile(sourcePath);
} }
catch (Exception ex) }
{ catch (Exception ex)
Logger?.LogError(ex, "Failed loading from {path}", sourcePath); {
} Logger?.LogError(ex, "Failed loading from {path}", sourcePath);
} }
}
Children.Clear();
Children.Clear();
if (imageOverlays != null)
{ if (imageOverlays != null)
foreach (var imageOverlay in imageOverlays) {
{ foreach (var imageOverlay in imageOverlays)
Children.Add(imageOverlay.Image); {
} Children.Add(imageOverlay.Image);
} }
} }
}
private static async Task<List<ImageOverlay>> LoadImageOverlaysFromArchive(string archiveFilePath)
{ private static async Task<List<ImageOverlay>> LoadImageOverlaysFromArchive(string archiveFilePath)
using var archive = ZipFile.OpenRead(archiveFilePath); {
using var archive = ZipFile.OpenRead(archiveFilePath);
var docEntry = archive.GetEntry("doc.kml") ??
archive.Entries.FirstOrDefault(e => e.Name.EndsWith(".kml")) ?? var docEntry = archive.GetEntry("doc.kml") ??
throw new ArgumentException($"No KML entry found in {archiveFilePath}."); archive.Entries.FirstOrDefault(e => e.Name.EndsWith(".kml")) ??
XElement element; throw new ArgumentException($"No KML entry found in {archiveFilePath}.");
XElement element;
using (var stream = docEntry.Open())
{ using (var stream = docEntry.Open())
element = await XDocument.LoadRootElementAsync(stream); {
} element = await XDocument.LoadRootElementAsync(stream);
}
return await LoadImageOverlays(element, imageOverlay => imageOverlay.LoadImage(archive));
} return await LoadImageOverlays(element, imageOverlay => imageOverlay.LoadImage(archive));
}
private static async Task<List<ImageOverlay>> LoadImageOverlaysFromFile(string docFilePath)
{ private static async Task<List<ImageOverlay>> LoadImageOverlaysFromFile(string docFilePath)
var docUri = new Uri(FilePath.GetFullPath(docFilePath)); {
XElement element; var docUri = new Uri(FilePath.GetFullPath(docFilePath));
XElement element;
using (var stream = File.OpenRead(docUri.AbsolutePath))
{ using (var stream = File.OpenRead(docUri.AbsolutePath))
element = await XDocument.LoadRootElementAsync(stream); {
} element = await XDocument.LoadRootElementAsync(stream);
}
return await LoadImageOverlays(element, imageOverlay => imageOverlay.LoadImage(docUri));
} return await LoadImageOverlays(element, imageOverlay => imageOverlay.LoadImage(docUri));
}
private static async Task<List<ImageOverlay>> LoadImageOverlays(XElement rootElement, Func<ImageOverlay, Task> loadFunc)
{ private static async Task<List<ImageOverlay>> LoadImageOverlays(XElement rootElement, Func<ImageOverlay, Task> loadFunc)
var imageOverlays = ReadImageOverlays(rootElement); {
var imageOverlays = ReadImageOverlays(rootElement);
await Task.WhenAll(imageOverlays.Select(loadFunc));
await Task.WhenAll(imageOverlays.Select(loadFunc));
return imageOverlays;
} return imageOverlays;
}
private static List<ImageOverlay> ReadImageOverlays(XElement rootElement)
{ private static List<ImageOverlay> ReadImageOverlays(XElement rootElement)
var ns = rootElement.Name.Namespace; {
var docElement = rootElement.Element(ns + "Document") ?? rootElement; var ns = rootElement.Name.Namespace;
var imageOverlays = new List<ImageOverlay>(); var docElement = rootElement.Element(ns + "Document") ?? rootElement;
var imageOverlays = new List<ImageOverlay>();
foreach (var folderElement in docElement.Elements(ns + "Folder"))
{ foreach (var folderElement in docElement.Elements(ns + "Folder"))
foreach (var groundOverlayElement in folderElement.Elements(ns + "GroundOverlay")) {
{ foreach (var groundOverlayElement in folderElement.Elements(ns + "GroundOverlay"))
var pathElement = groundOverlayElement.Element(ns + "Icon"); {
var path = pathElement?.Element(ns + "href")?.Value; var pathElement = groundOverlayElement.Element(ns + "Icon");
var path = pathElement?.Element(ns + "href")?.Value;
var latLonBoxElement = groundOverlayElement.Element(ns + "LatLonBox");
var latLonBox = latLonBoxElement != null ? ReadLatLonBox(latLonBoxElement) : null; var latLonBoxElement = groundOverlayElement.Element(ns + "LatLonBox");
var latLonBox = latLonBoxElement != null ? ReadLatLonBox(latLonBoxElement) : null;
var drawOrder = groundOverlayElement.Element(ns + "drawOrder")?.Value;
var zIndex = drawOrder != null ? int.Parse(drawOrder) : 0; var drawOrder = groundOverlayElement.Element(ns + "drawOrder")?.Value;
var zIndex = drawOrder != null ? int.Parse(drawOrder) : 0;
if (latLonBox != null && path != null)
{ if (latLonBox != null && path != null)
imageOverlays.Add(new ImageOverlay(path, latLonBox, zIndex)); {
} imageOverlays.Add(new ImageOverlay(path, latLonBox, zIndex));
} }
} }
}
return imageOverlays;
} return imageOverlays;
}
private static BoundingBox ReadLatLonBox(XElement latLonBoxElement)
{ private static BoundingBox ReadLatLonBox(XElement latLonBoxElement)
var ns = latLonBoxElement.Name.Namespace; {
var north = double.NaN; var ns = latLonBoxElement.Name.Namespace;
var south = double.NaN; var north = double.NaN;
var east = double.NaN; var south = double.NaN;
var west = double.NaN; var east = double.NaN;
var west = double.NaN;
var value = latLonBoxElement.Element(ns + "north")?.Value;
if (value != null) var value = latLonBoxElement.Element(ns + "north")?.Value;
{ if (value != null)
north = double.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture); {
} north = double.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture);
}
value = latLonBoxElement.Element(ns + "south")?.Value;
if (value != null) value = latLonBoxElement.Element(ns + "south")?.Value;
{ if (value != null)
south = double.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture); {
} south = double.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture);
}
value = latLonBoxElement.Element(ns + "east")?.Value;
if (value != null) value = latLonBoxElement.Element(ns + "east")?.Value;
{ if (value != null)
east = double.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture); {
} east = double.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture);
}
value = latLonBoxElement.Element(ns + "west")?.Value;
if (value != null) value = latLonBoxElement.Element(ns + "west")?.Value;
{ if (value != null)
west = double.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture); {
} west = double.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture);
}
if (double.IsNaN(north) || double.IsNaN(south) ||
double.IsNaN(east) || double.IsNaN(west) || if (double.IsNaN(north) || double.IsNaN(south) ||
north <= south || east <= west) double.IsNaN(east) || double.IsNaN(west) ||
{ north <= south || east <= west)
throw new FormatException("Invalid LatLonBox"); {
} throw new FormatException("Invalid LatLonBox");
}
return new BoundingBox(south, west, north, east);
} return new BoundingBox(south, west, north, east);
} }
} }

View file

@ -8,356 +8,355 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace MapControl.Caching namespace MapControl.Caching;
public class ImageFileCacheOptions : IOptions<ImageFileCacheOptions>
{ {
public class ImageFileCacheOptions : IOptions<ImageFileCacheOptions> public ImageFileCacheOptions Value => this;
public string Path { get; set; }
public TimeSpan ExpirationScanFrequency { get; set; } = TimeSpan.FromHours(1);
}
/// <summary>
/// IDistributedCache implementation that creates a single file per cache entry.
/// The cache expiration time is stored in the file's CreationTime property.
/// </summary>
public sealed partial class ImageFileCache : IDistributedCache, IDisposable
{
private readonly MemoryDistributedCache memoryCache;
private readonly DirectoryInfo rootDirectory;
private readonly Timer expirationScanTimer;
private readonly ILogger logger;
private bool scanningExpiration;
public ImageFileCache(string path, ILoggerFactory loggerFactory = null)
: this(new ImageFileCacheOptions { Path = path }, loggerFactory)
{ {
public ImageFileCacheOptions Value => this;
public string Path { get; set; }
public TimeSpan ExpirationScanFrequency { get; set; } = TimeSpan.FromHours(1);
} }
/// <summary> public ImageFileCache(IOptions<ImageFileCacheOptions> optionsAccessor, ILoggerFactory loggerFactory = null)
/// IDistributedCache implementation that creates a single file per cache entry. : this(optionsAccessor.Value, loggerFactory)
/// The cache expiration time is stored in the file's CreationTime property.
/// </summary>
public sealed partial class ImageFileCache : IDistributedCache, IDisposable
{ {
private readonly MemoryDistributedCache memoryCache; }
private readonly DirectoryInfo rootDirectory;
private readonly Timer expirationScanTimer;
private readonly ILogger logger;
private bool scanningExpiration;
public ImageFileCache(string path, ILoggerFactory loggerFactory = null) public ImageFileCache(ImageFileCacheOptions options, ILoggerFactory loggerFactory = null)
: this(new ImageFileCacheOptions { Path = path }, loggerFactory) {
var path = options.Path;
rootDirectory = new DirectoryInfo(!string.IsNullOrEmpty(path) ? path : "TileCache");
rootDirectory.Create();
logger = loggerFactory?.CreateLogger(typeof(ImageFileCache));
logger?.LogInformation("Started in {name}", rootDirectory.FullName);
var memoryCacheOptions = new MemoryDistributedCacheOptions();
if (options.ExpirationScanFrequency > TimeSpan.Zero)
{ {
memoryCacheOptions.ExpirationScanFrequency = options.ExpirationScanFrequency;
expirationScanTimer = new Timer(_ => DeleteExpiredItems(), null, TimeSpan.Zero, options.ExpirationScanFrequency);
} }
public ImageFileCache(IOptions<ImageFileCacheOptions> optionsAccessor, ILoggerFactory loggerFactory = null) memoryCache = new MemoryDistributedCache(Options.Create(memoryCacheOptions));
: this(optionsAccessor.Value, loggerFactory) }
public void Dispose()
{
expirationScanTimer?.Dispose();
}
public byte[] Get(string key)
{
byte[] value = null;
if (!string.IsNullOrEmpty(key))
{ {
} value = memoryCache.Get(key);
public ImageFileCache(ImageFileCacheOptions options, ILoggerFactory loggerFactory = null) if (value == null)
{
var path = options.Path;
rootDirectory = new DirectoryInfo(!string.IsNullOrEmpty(path) ? path : "TileCache");
rootDirectory.Create();
logger = loggerFactory?.CreateLogger(typeof(ImageFileCache));
logger?.LogInformation("Started in {name}", rootDirectory.FullName);
var memoryCacheOptions = new MemoryDistributedCacheOptions();
if (options.ExpirationScanFrequency > TimeSpan.Zero)
{ {
memoryCacheOptions.ExpirationScanFrequency = options.ExpirationScanFrequency;
expirationScanTimer = new Timer(_ => DeleteExpiredItems(), null, TimeSpan.Zero, options.ExpirationScanFrequency);
}
memoryCache = new MemoryDistributedCache(Options.Create(memoryCacheOptions));
}
public void Dispose()
{
expirationScanTimer?.Dispose();
}
public byte[] Get(string key)
{
byte[] value = null;
if (!string.IsNullOrEmpty(key))
{
value = memoryCache.Get(key);
if (value == null)
{
var file = GetFile(key);
try
{
if (file != null && file.Exists && file.CreationTime > DateTime.Now)
{
value = ReadAllBytes(file);
var options = new DistributedCacheEntryOptions { AbsoluteExpiration = file.CreationTime };
memoryCache.Set(key, value, options);
logger?.LogDebug("Read {name}", file.FullName);
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed reading {name}", file.FullName);
}
}
}
return value;
}
public async Task<byte[]> GetAsync(string key, CancellationToken token = default)
{
byte[] value = null;
if (!string.IsNullOrEmpty(key))
{
value = await memoryCache.GetAsync(key, token).ConfigureAwait(false);
if (value == null)
{
var file = GetFile(key);
try
{
if (file != null && file.Exists && file.CreationTime > DateTime.Now && !token.IsCancellationRequested)
{
value = await ReadAllBytes(file, token).ConfigureAwait(false);
var options = new DistributedCacheEntryOptions { AbsoluteExpiration = file.CreationTime };
await memoryCache.SetAsync(key, value, options, token).ConfigureAwait(false);
logger?.LogDebug("Read {name}", file.FullName);
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed reading {name}", file.FullName);
}
}
}
return value;
}
public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
{
if (!string.IsNullOrEmpty(key) && value != null && options != null)
{
memoryCache.Set(key, value, options);
var file = GetFile(key); var file = GetFile(key);
try try
{ {
if (file != null && value?.Length > 0) if (file != null && file.Exists && file.CreationTime > DateTime.Now)
{ {
file.Directory.Create(); value = ReadAllBytes(file);
using (var stream = file.Create()) var options = new DistributedCacheEntryOptions { AbsoluteExpiration = file.CreationTime };
{
stream.Write(value, 0, value.Length);
}
SetExpiration(file, options); memoryCache.Set(key, value, options);
logger?.LogDebug("Wrote {name}", file.FullName); logger?.LogDebug("Read {name}", file.FullName);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
logger?.LogError(ex, "Failed writing {name}", file.FullName); logger?.LogError(ex, "Failed reading {name}", file.FullName);
} }
} }
} }
public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) return value;
{ }
if (!string.IsNullOrEmpty(key) && value != null && options != null)
{
await memoryCache.SetAsync(key, value, options, token).ConfigureAwait(false);
public async Task<byte[]> GetAsync(string key, CancellationToken token = default)
{
byte[] value = null;
if (!string.IsNullOrEmpty(key))
{
value = await memoryCache.GetAsync(key, token).ConfigureAwait(false);
if (value == null)
{
var file = GetFile(key); var file = GetFile(key);
try try
{ {
if (file != null && value?.Length > 0 && !token.IsCancellationRequested) if (file != null && file.Exists && file.CreationTime > DateTime.Now && !token.IsCancellationRequested)
{ {
file.Directory.Create(); value = await ReadAllBytes(file, token).ConfigureAwait(false);
using (var stream = file.Create()) var options = new DistributedCacheEntryOptions { AbsoluteExpiration = file.CreationTime };
{
await stream.WriteAsync(value, 0, value.Length, token).ConfigureAwait(false);
}
SetExpiration(file, options); await memoryCache.SetAsync(key, value, options, token).ConfigureAwait(false);
logger?.LogDebug("Wrote {name}", file.FullName); logger?.LogDebug("Read {name}", file.FullName);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
logger?.LogError(ex, "Failed writing {name}", file.FullName); logger?.LogError(ex, "Failed reading {name}", file.FullName);
} }
} }
} }
public void Refresh(string key) return value;
}
public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
{
if (!string.IsNullOrEmpty(key) && value != null && options != null)
{ {
if (!string.IsNullOrEmpty(key)) memoryCache.Set(key, value, options);
{
memoryCache.Refresh(key);
}
}
public async Task RefreshAsync(string key, CancellationToken token = default) var file = GetFile(key);
{
if (!string.IsNullOrEmpty(key))
{
await memoryCache.RefreshAsync(key, token);
}
}
public void Remove(string key)
{
if (!string.IsNullOrEmpty(key))
{
memoryCache.Remove(key);
var file = GetFile(key);
try
{
if (file != null && file.Exists)
{
file.Delete();
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed deleting {name}", file.FullName);
}
}
}
public async Task RemoveAsync(string key, CancellationToken token = default)
{
if (!string.IsNullOrEmpty(key))
{
await memoryCache.RemoveAsync(key, token);
var file = GetFile(key);
try
{
if (file != null && file.Exists && !token.IsCancellationRequested)
{
file.Delete();
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed deleting {name}", file.FullName);
}
}
}
public void DeleteExpiredItems()
{
if (!scanningExpiration)
{
scanningExpiration = true;
foreach (var directory in rootDirectory.EnumerateDirectories())
{
var deletedFileCount = ScanDirectory(directory);
if (deletedFileCount > 0)
{
logger?.LogInformation("Deleted {count} expired items in {name}", deletedFileCount, directory.FullName);
}
}
scanningExpiration = false;
}
}
private int ScanDirectory(DirectoryInfo directory)
{
var deletedFileCount = 0;
try try
{ {
deletedFileCount = directory.EnumerateDirectories().Sum(ScanDirectory); if (file != null && value?.Length > 0)
{
file.Directory.Create();
foreach (var file in directory.EnumerateFiles() using (var stream = file.Create())
.Where(file => file.CreationTime > file.LastWriteTime && {
file.CreationTime <= DateTime.Now)) stream.Write(value, 0, value.Length);
}
SetExpiration(file, options);
logger?.LogDebug("Wrote {name}", file.FullName);
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed writing {name}", file.FullName);
}
}
}
public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default)
{
if (!string.IsNullOrEmpty(key) && value != null && options != null)
{
await memoryCache.SetAsync(key, value, options, token).ConfigureAwait(false);
var file = GetFile(key);
try
{
if (file != null && value?.Length > 0 && !token.IsCancellationRequested)
{
file.Directory.Create();
using (var stream = file.Create())
{
await stream.WriteAsync(value, 0, value.Length, token).ConfigureAwait(false);
}
SetExpiration(file, options);
logger?.LogDebug("Wrote {name}", file.FullName);
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed writing {name}", file.FullName);
}
}
}
public void Refresh(string key)
{
if (!string.IsNullOrEmpty(key))
{
memoryCache.Refresh(key);
}
}
public async Task RefreshAsync(string key, CancellationToken token = default)
{
if (!string.IsNullOrEmpty(key))
{
await memoryCache.RefreshAsync(key, token);
}
}
public void Remove(string key)
{
if (!string.IsNullOrEmpty(key))
{
memoryCache.Remove(key);
var file = GetFile(key);
try
{
if (file != null && file.Exists)
{ {
file.Delete(); file.Delete();
deletedFileCount++;
}
if (!directory.EnumerateFileSystemInfos().Any())
{
directory.Delete();
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
logger?.LogError(ex, "Failed cleaning {name}", directory.FullName); logger?.LogError(ex, "Failed deleting {name}", file.FullName);
} }
return deletedFileCount;
} }
}
private FileInfo GetFile(string key) public async Task RemoveAsync(string key, CancellationToken token = default)
{
if (!string.IsNullOrEmpty(key))
{ {
FileInfo file = null; await memoryCache.RemoveAsync(key, token);
var file = GetFile(key);
try try
{ {
file = new FileInfo(Path.Combine(rootDirectory.FullName, Path.Combine(key.Split('/')))); if (file != null && file.Exists && !token.IsCancellationRequested)
{
file.Delete();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
logger?.LogError(ex, "Invalid key {key}", key); logger?.LogError(ex, "Failed deleting {name}", file.FullName);
} }
return file;
}
private static byte[] ReadAllBytes(FileInfo file)
{
using var stream = file.Open(FileMode.Open, FileAccess.Read, FileShare.Read);
var buffer = new byte[stream.Length];
var offset = 0;
while (offset < buffer.Length)
{
offset += stream.Read(buffer, offset, buffer.Length - offset);
}
return buffer;
}
private static async Task<byte[]> ReadAllBytes(FileInfo file, CancellationToken token)
{
using var stream = file.Open(FileMode.Open, FileAccess.Read, FileShare.Read);
var buffer = new byte[stream.Length];
var offset = 0;
while (offset < buffer.Length)
{
offset += await stream.ReadAsync(buffer, offset, buffer.Length - offset, token).ConfigureAwait(false);
}
return buffer;
}
private static void SetExpiration(FileInfo file, DistributedCacheEntryOptions options)
{
file.CreationTime = options.AbsoluteExpiration.HasValue
? options.AbsoluteExpiration.Value.LocalDateTime
: DateTime.Now.Add(options.AbsoluteExpirationRelativeToNow ?? options.SlidingExpiration ?? TimeSpan.FromDays(1));
} }
} }
public void DeleteExpiredItems()
{
if (!scanningExpiration)
{
scanningExpiration = true;
foreach (var directory in rootDirectory.EnumerateDirectories())
{
var deletedFileCount = ScanDirectory(directory);
if (deletedFileCount > 0)
{
logger?.LogInformation("Deleted {count} expired items in {name}", deletedFileCount, directory.FullName);
}
}
scanningExpiration = false;
}
}
private int ScanDirectory(DirectoryInfo directory)
{
var deletedFileCount = 0;
try
{
deletedFileCount = directory.EnumerateDirectories().Sum(ScanDirectory);
foreach (var file in directory.EnumerateFiles()
.Where(file => file.CreationTime > file.LastWriteTime &&
file.CreationTime <= DateTime.Now))
{
file.Delete();
deletedFileCount++;
}
if (!directory.EnumerateFileSystemInfos().Any())
{
directory.Delete();
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed cleaning {name}", directory.FullName);
}
return deletedFileCount;
}
private FileInfo GetFile(string key)
{
FileInfo file = null;
try
{
file = new FileInfo(Path.Combine(rootDirectory.FullName, Path.Combine(key.Split('/'))));
}
catch (Exception ex)
{
logger?.LogError(ex, "Invalid key {key}", key);
}
return file;
}
private static byte[] ReadAllBytes(FileInfo file)
{
using var stream = file.Open(FileMode.Open, FileAccess.Read, FileShare.Read);
var buffer = new byte[stream.Length];
var offset = 0;
while (offset < buffer.Length)
{
offset += stream.Read(buffer, offset, buffer.Length - offset);
}
return buffer;
}
private static async Task<byte[]> ReadAllBytes(FileInfo file, CancellationToken token)
{
using var stream = file.Open(FileMode.Open, FileAccess.Read, FileShare.Read);
var buffer = new byte[stream.Length];
var offset = 0;
while (offset < buffer.Length)
{
offset += await stream.ReadAsync(buffer, offset, buffer.Length - offset, token).ConfigureAwait(false);
}
return buffer;
}
private static void SetExpiration(FileInfo file, DistributedCacheEntryOptions options)
{
file.CreationTime = options.AbsoluteExpiration.HasValue
? options.AbsoluteExpiration.Value.LocalDateTime
: DateTime.Now.Add(options.AbsoluteExpirationRelativeToNow ?? options.SlidingExpiration ?? TimeSpan.FromDays(1));
}
} }

View file

@ -13,146 +13,145 @@ using Microsoft.UI.Xaml.Media;
using ImageSource = Avalonia.Media.IImage; using ImageSource = Avalonia.Media.IImage;
#endif #endif
namespace MapControl namespace MapControl;
public static partial class ImageLoader
{ {
public static partial class ImageLoader private static ILogger Logger => field ??= LoggerFactory?.CreateLogger(typeof(ImageLoader));
public static ILoggerFactory LoggerFactory { get; set; }
/// <summary>
/// The System.Net.Http.HttpClient instance used to download images.
/// An application should add a unique User-Agent value to the DefaultRequestHeaders of this
/// HttpClient instance (or the Headers of a HttpRequestMessage used in a HttpMessageHandler).
/// Failing to set a unique User-Agent value is a violation of OpenStreetMap's tile usage policy
/// (see https://operations.osmfoundation.org/policies/tiles/) and results in blocked access
/// to their tile servers.
/// </summary>
public static HttpClient HttpClient
{ {
private static ILogger Logger => field ??= LoggerFactory?.CreateLogger(typeof(ImageLoader)); get => field ??= new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
set;
}
public static ILoggerFactory LoggerFactory { get; set; } public static bool IsHttp(this Uri uri)
{
return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps;
}
/// <summary> public static async Task<ImageSource> LoadImageAsync(byte[] buffer)
/// The System.Net.Http.HttpClient instance used to download images. {
/// An application should add a unique User-Agent value to the DefaultRequestHeaders of this using var stream = new MemoryStream(buffer);
/// HttpClient instance (or the Headers of a HttpRequestMessage used in a HttpMessageHandler).
/// Failing to set a unique User-Agent value is a violation of OpenStreetMap's tile usage policy return await LoadImageAsync(stream);
/// (see https://operations.osmfoundation.org/policies/tiles/) and results in blocked access }
/// to their tile servers.
/// </summary> public static async Task<ImageSource> LoadImageAsync(Uri uri, IProgress<double> progress = null)
public static HttpClient HttpClient {
ImageSource image = null;
progress?.Report(0d);
try
{ {
get => field ??= new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; if (!uri.IsAbsoluteUri)
set; {
image = await LoadImageAsync(uri.OriginalString);
}
else if (uri.IsHttp())
{
var buffer = await GetHttpContent(uri, progress);
if (buffer != null)
{
image = await LoadImageAsync(buffer);
}
}
else if (uri.IsFile)
{
image = await LoadImageAsync(uri.LocalPath);
}
else
{
image = LoadResourceImage(uri);
}
}
catch (Exception ex)
{
Logger?.LogError(ex, "Failed loading image from {uri}", uri);
} }
public static bool IsHttp(this Uri uri) progress?.Report(1d);
return image;
}
public static async Task<HttpResponseMessage> GetHttpResponseAsync(Uri uri, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
{
try
{ {
return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps; var response = await HttpClient.GetAsync(uri, completionOption).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return response;
}
Logger?.LogWarning("{status} ({reason}) from {uri}", (int)response.StatusCode, response.ReasonPhrase, uri);
response.Dispose();
}
catch (TaskCanceledException)
{
Logger?.LogWarning("Timeout from {uri}", uri);
}
catch (Exception ex)
{
Logger?.LogError(ex, "{uri}", uri);
} }
public static async Task<ImageSource> LoadImageAsync(byte[] buffer) return null;
}
private static async Task<byte[]> GetHttpContent(Uri uri, IProgress<double> progress)
{
var completionOption = progress != null ? HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead;
using var response = await GetHttpResponseAsync(uri, completionOption).ConfigureAwait(false);
if (response == null)
{ {
using var stream = new MemoryStream(buffer);
return await LoadImageAsync(stream);
}
public static async Task<ImageSource> LoadImageAsync(Uri uri, IProgress<double> progress = null)
{
ImageSource image = null;
progress?.Report(0d);
try
{
if (!uri.IsAbsoluteUri)
{
image = await LoadImageAsync(uri.OriginalString);
}
else if (uri.IsHttp())
{
var buffer = await GetHttpContent(uri, progress);
if (buffer != null)
{
image = await LoadImageAsync(buffer);
}
}
else if (uri.IsFile)
{
image = await LoadImageAsync(uri.LocalPath);
}
else
{
image = LoadResourceImage(uri);
}
}
catch (Exception ex)
{
Logger?.LogError(ex, "Failed loading image from {uri}", uri);
}
progress?.Report(1d);
return image;
}
public static async Task<HttpResponseMessage> GetHttpResponseAsync(Uri uri, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
{
try
{
var response = await HttpClient.GetAsync(uri, completionOption).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return response;
}
Logger?.LogWarning("{status} ({reason}) from {uri}", (int)response.StatusCode, response.ReasonPhrase, uri);
response.Dispose();
}
catch (TaskCanceledException)
{
Logger?.LogWarning("Timeout from {uri}", uri);
}
catch (Exception ex)
{
Logger?.LogError(ex, "{uri}", uri);
}
return null; return null;
} }
private static async Task<byte[]> GetHttpContent(Uri uri, IProgress<double> progress) var content = response.Content;
var contentLength = content.Headers.ContentLength;
if (progress == null || !contentLength.HasValue)
{ {
var completionOption = progress != null ? HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead; return await content.ReadAsByteArrayAsync().ConfigureAwait(false);
}
using var response = await GetHttpResponseAsync(uri, completionOption).ConfigureAwait(false); var length = (int)contentLength.Value;
var buffer = new byte[length];
if (response == null) using (var stream = await content.ReadAsStreamAsync().ConfigureAwait(false))
{
int offset = 0;
int read;
while (offset < length &&
(read = await stream.ReadAsync(buffer, offset, length - offset).ConfigureAwait(false)) > 0)
{ {
return null; offset += read;
}
var content = response.Content; if (offset < length) // 1.0 reported by caller
var contentLength = content.Headers.ContentLength;
if (progress == null || !contentLength.HasValue)
{
return await content.ReadAsByteArrayAsync().ConfigureAwait(false);
}
var length = (int)contentLength.Value;
var buffer = new byte[length];
using (var stream = await content.ReadAsStreamAsync().ConfigureAwait(false))
{
int offset = 0;
int read;
while (offset < length &&
(read = await stream.ReadAsync(buffer, offset, length - offset).ConfigureAwait(false)) > 0)
{ {
offset += read; progress.Report((double)offset / length);
if (offset < length) // 1.0 reported by caller
{
progress.Report((double)offset / length);
}
} }
} }
return buffer;
} }
return buffer;
} }
} }

View file

@ -1,47 +1,46 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
namespace MapControl namespace MapControl;
public partial class ImageTileList : List<ImageTile>
{ {
public partial class ImageTileList : List<ImageTile> public ImageTileList()
{ {
public ImageTileList() }
{
}
public ImageTileList(IEnumerable<ImageTile> source, TileMatrix tileMatrix, int columnCount) public ImageTileList(IEnumerable<ImageTile> source, TileMatrix tileMatrix, int columnCount)
: base(tileMatrix.Width * tileMatrix.Height) : base(tileMatrix.Width * tileMatrix.Height)
{ {
FillMatrix(source, tileMatrix.ZoomLevel, tileMatrix.XMin, tileMatrix.YMin, tileMatrix.XMax, tileMatrix.YMax, columnCount); FillMatrix(source, tileMatrix.ZoomLevel, tileMatrix.XMin, tileMatrix.YMin, tileMatrix.XMax, tileMatrix.YMax, columnCount);
} }
/// <summary> /// <summary>
/// Adds existing ImageTile from the source collection or newly created ImageTile to fill the specified tile matrix. /// Adds existing ImageTile from the source collection or newly created ImageTile to fill the specified tile matrix.
/// </summary> /// </summary>
public void FillMatrix(IEnumerable<ImageTile> source, int zoomLevel, int xMin, int yMin, int xMax, int yMax, int columnCount) public void FillMatrix(IEnumerable<ImageTile> source, int zoomLevel, int xMin, int yMin, int xMax, int yMax, int columnCount)
{
for (var y = yMin; y <= yMax; y++)
{ {
for (var y = yMin; y <= yMax; y++) for (var x = xMin; x <= xMax; x++)
{ {
for (var x = xMin; x <= xMax; x++) var tile = source.FirstOrDefault(t => t.ZoomLevel == zoomLevel && t.X == x && t.Y == y);
if (tile == null)
{ {
var tile = source.FirstOrDefault(t => t.ZoomLevel == zoomLevel && t.X == x && t.Y == y); tile = new ImageTile(zoomLevel, x, y, columnCount);
if (tile == null) var equivalentTile = source.FirstOrDefault(
t => t.Image.Source != null && t.ZoomLevel == tile.ZoomLevel && t.Column == tile.Column && t.Row == tile.Row);
if (equivalentTile != null)
{ {
tile = new ImageTile(zoomLevel, x, y, columnCount); tile.IsPending = false;
tile.Image.Source = equivalentTile.Image.Source; // no Opacity animation
var equivalentTile = source.FirstOrDefault(
t => t.Image.Source != null && t.ZoomLevel == tile.ZoomLevel && t.Column == tile.Column && t.Row == tile.Row);
if (equivalentTile != null)
{
tile.IsPending = false;
tile.Image.Source = equivalentTile.Image.Source; // no Opacity animation
}
} }
Add(tile);
} }
Add(tile);
} }
} }
} }

View file

@ -1,129 +1,128 @@
using System; using System;
using System.Globalization; using System.Globalization;
namespace MapControl namespace MapControl;
{
/// <summary> /// <summary>
/// A geographic location with latitude and longitude values in degrees. /// A geographic location with latitude and longitude values in degrees.
/// </summary> /// </summary>
#if UWP || WINUI #if UWP || WINUI
[Windows.Foundation.Metadata.CreateFromString(MethodName = "Parse")] [Windows.Foundation.Metadata.CreateFromString(MethodName = "Parse")]
#else #else
[System.ComponentModel.TypeConverter(typeof(LocationConverter))] [System.ComponentModel.TypeConverter(typeof(LocationConverter))]
#endif #endif
public class Location(double latitude, double longitude) : IEquatable<Location> public class Location(double latitude, double longitude) : IEquatable<Location>
{
public double Latitude { get; } = Math.Min(Math.Max(latitude, -90d), 90d);
public double Longitude => longitude;
public bool LatitudeEquals(double latitude) => Math.Abs(Latitude - latitude) < 1e-9;
public bool LongitudeEquals(double longitude) => Math.Abs(Longitude - longitude) < 1e-9;
public bool Equals(double latitude, double longitude) => LatitudeEquals(latitude) && LongitudeEquals(longitude);
public bool Equals(Location location) => location != null && Equals(location.Latitude, location.Longitude);
public override bool Equals(object obj) => Equals(obj as Location);
public override int GetHashCode() => Latitude.GetHashCode() ^ Longitude.GetHashCode();
public override string ToString() => string.Format(CultureInfo.InvariantCulture, "{0},{1}", Latitude, Longitude);
/// <summary>
/// Creates a Location instance from a string containing a comma-separated pair of floating point numbers.
/// </summary>
public static Location Parse(string location)
{ {
public double Latitude { get; } = Math.Min(Math.Max(latitude, -90d), 90d); string[] values = null;
public double Longitude => longitude;
public bool LatitudeEquals(double latitude) => Math.Abs(Latitude - latitude) < 1e-9; if (!string.IsNullOrEmpty(location))
public bool LongitudeEquals(double longitude) => Math.Abs(Longitude - longitude) < 1e-9;
public bool Equals(double latitude, double longitude) => LatitudeEquals(latitude) && LongitudeEquals(longitude);
public bool Equals(Location location) => location != null && Equals(location.Latitude, location.Longitude);
public override bool Equals(object obj) => Equals(obj as Location);
public override int GetHashCode() => Latitude.GetHashCode() ^ Longitude.GetHashCode();
public override string ToString() => string.Format(CultureInfo.InvariantCulture, "{0},{1}", Latitude, Longitude);
/// <summary>
/// Creates a Location instance from a string containing a comma-separated pair of floating point numbers.
/// </summary>
public static Location Parse(string location)
{ {
string[] values = null; values = location.Split(',');
if (!string.IsNullOrEmpty(location))
{
values = location.Split(',');
}
if (values?.Length != 2)
{
throw new FormatException($"{nameof(Location)} string must contain a comma-separated pair of floating point numbers.");
}
return new Location(
double.Parse(values[0], NumberStyles.Float, CultureInfo.InvariantCulture),
double.Parse(values[1], NumberStyles.Float, CultureInfo.InvariantCulture));
} }
/// <summary> if (values?.Length != 2)
/// Normalizes a longitude to a value in the interval [-180 .. 180).
/// </summary>
public static double NormalizeLongitude(double longitude)
{ {
var x = (longitude + 180d) % 360d; throw new FormatException($"{nameof(Location)} string must contain a comma-separated pair of floating point numbers.");
return x < 0d ? x + 180d : x - 180d;
} }
// Arithmetic mean radius (2*a + b) / 3 == (1 - f/3) * a. return new Location(
// See https://en.wikipedia.org/wiki/Earth_radius#Arithmetic_mean_radius. double.Parse(values[0], NumberStyles.Float, CultureInfo.InvariantCulture),
// double.Parse(values[1], NumberStyles.Float, CultureInfo.InvariantCulture));
public const double Wgs84MeanRadius = (1d - MapProjection.Wgs84Flattening / 3d) * MapProjection.Wgs84EquatorialRadius; }
/// <summary> /// <summary>
/// Calculates great circle azimuth in degrees and distance in meters between this and the specified Location. /// Normalizes a longitude to a value in the interval [-180 .. 180).
/// See https://en.wikipedia.org/wiki/Great-circle_navigation#Course. /// </summary>
/// </summary> public static double NormalizeLongitude(double longitude)
public (double, double) GetAzimuthDistance(Location location, double earthRadius = Wgs84MeanRadius) {
{ var x = (longitude + 180d) % 360d;
var lat1 = Latitude * Math.PI / 180d;
var lon1 = Longitude * Math.PI / 180d;
var lat2 = location.Latitude * Math.PI / 180d;
var lon2 = location.Longitude * Math.PI / 180d;
var cosLat1 = Math.Cos(lat1);
var sinLat1 = Math.Sin(lat1);
var cosLat2 = Math.Cos(lat2);
var sinLat2 = Math.Sin(lat2);
var cosLon12 = Math.Cos(lon2 - lon1);
var sinLon12 = Math.Sin(lon2 - lon1);
var a = cosLat2 * sinLon12;
var b = cosLat1 * sinLat2 - sinLat1 * cosLat2 * cosLon12;
// α1
var azimuth = Math.Atan2(a, b);
// σ12
var distance = Math.Atan2(Math.Sqrt(a * a + b * b), sinLat1 * sinLat2 + cosLat1 * cosLat2 * cosLon12);
return (azimuth * 180d / Math.PI, distance * earthRadius); return x < 0d ? x + 180d : x - 180d;
} }
/// <summary> // Arithmetic mean radius (2*a + b) / 3 == (1 - f/3) * a.
/// Calculates great distance in meters between this and the specified Location. // See https://en.wikipedia.org/wiki/Earth_radius#Arithmetic_mean_radius.
/// See https://en.wikipedia.org/wiki/Great-circle_navigation#Course. //
/// </summary> public const double Wgs84MeanRadius = (1d - MapProjection.Wgs84Flattening / 3d) * MapProjection.Wgs84EquatorialRadius;
public double GetDistance(Location location, double earthRadius = Wgs84MeanRadius)
{
(var _, var distance) = GetAzimuthDistance(location, earthRadius);
return distance; /// <summary>
} /// Calculates great circle azimuth in degrees and distance in meters between this and the specified Location.
/// See https://en.wikipedia.org/wiki/Great-circle_navigation#Course.
/// </summary>
public (double, double) GetAzimuthDistance(Location location, double earthRadius = Wgs84MeanRadius)
{
var lat1 = Latitude * Math.PI / 180d;
var lon1 = Longitude * Math.PI / 180d;
var lat2 = location.Latitude * Math.PI / 180d;
var lon2 = location.Longitude * Math.PI / 180d;
var cosLat1 = Math.Cos(lat1);
var sinLat1 = Math.Sin(lat1);
var cosLat2 = Math.Cos(lat2);
var sinLat2 = Math.Sin(lat2);
var cosLon12 = Math.Cos(lon2 - lon1);
var sinLon12 = Math.Sin(lon2 - lon1);
var a = cosLat2 * sinLon12;
var b = cosLat1 * sinLat2 - sinLat1 * cosLat2 * cosLon12;
// α1
var azimuth = Math.Atan2(a, b);
// σ12
var distance = Math.Atan2(Math.Sqrt(a * a + b * b), sinLat1 * sinLat2 + cosLat1 * cosLat2 * cosLon12);
/// <summary> return (azimuth * 180d / Math.PI, distance * earthRadius);
/// Calculates the Location on a great circle at the specified azimuth in degrees and distance in meters from this Location. }
/// See https://en.wikipedia.org/wiki/Great-circle_navigation#Finding_way-points.
/// </summary>
public Location GetLocation(double azimuth, double distance, double earthRadius = Wgs84MeanRadius)
{
var lat1 = Latitude * Math.PI / 180d;
var lon1 = Longitude * Math.PI / 180d;
var a = azimuth * Math.PI / 180d;
var d = distance / earthRadius;
var cosLat1 = Math.Cos(lat1);
var sinLat1 = Math.Sin(lat1);
var cosA = Math.Cos(a);
var sinA = Math.Sin(a);
var cosD = Math.Cos(d);
var sinD = Math.Sin(d);
var lat2 = Math.Asin(sinLat1 * cosD + cosLat1 * sinD * cosA);
var lon2 = lon1 + Math.Atan2(sinD * sinA, cosLat1 * cosD - sinLat1 * sinD * cosA);
return new Location(lat2 * 180d / Math.PI, lon2 * 180d / Math.PI); /// <summary>
} /// Calculates great distance in meters between this and the specified Location.
/// See https://en.wikipedia.org/wiki/Great-circle_navigation#Course.
/// </summary>
public double GetDistance(Location location, double earthRadius = Wgs84MeanRadius)
{
(var _, var distance) = GetAzimuthDistance(location, earthRadius);
return distance;
}
/// <summary>
/// Calculates the Location on a great circle at the specified azimuth in degrees and distance in meters from this Location.
/// See https://en.wikipedia.org/wiki/Great-circle_navigation#Finding_way-points.
/// </summary>
public Location GetLocation(double azimuth, double distance, double earthRadius = Wgs84MeanRadius)
{
var lat1 = Latitude * Math.PI / 180d;
var lon1 = Longitude * Math.PI / 180d;
var a = azimuth * Math.PI / 180d;
var d = distance / earthRadius;
var cosLat1 = Math.Cos(lat1);
var sinLat1 = Math.Sin(lat1);
var cosA = Math.Cos(a);
var sinA = Math.Sin(a);
var cosD = Math.Cos(d);
var sinD = Math.Sin(d);
var lat2 = Math.Asin(sinLat1 * cosD + cosLat1 * sinD * cosA);
var lon2 = lon1 + Math.Atan2(sinD * sinA, cosLat1 * cosD - sinLat1 * sinD * cosA);
return new Location(lat2 * 180d / Math.PI, lon2 * 180d / Math.PI);
} }
} }

View file

@ -2,218 +2,217 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
namespace MapControl namespace MapControl;
{
/// <summary> /// <summary>
/// A collection of Locations with support for string parsing /// A collection of Locations with support for string parsing
/// and calculation of great circle and rhumb line locations. /// and calculation of great circle and rhumb line locations.
/// </summary> /// </summary>
#if UWP || WINUI #if UWP || WINUI
[Windows.Foundation.Metadata.CreateFromString(MethodName = "Parse")] [Windows.Foundation.Metadata.CreateFromString(MethodName = "Parse")]
#else #else
[System.ComponentModel.TypeConverter(typeof(LocationCollectionConverter))] [System.ComponentModel.TypeConverter(typeof(LocationCollectionConverter))]
#endif #endif
public partial class LocationCollection : List<Location> public partial class LocationCollection : List<Location>
{
public LocationCollection()
{ {
public LocationCollection() }
public LocationCollection(IEnumerable<Location> locations)
: base(locations)
{
}
public LocationCollection(params Location[] locations)
: base(locations)
{
}
public void Add(double latitude, double longitude)
{
if (Count > 0)
{ {
var deltaLon = longitude - this[Count - 1].Longitude;
if (deltaLon < -180d)
{
longitude += 360d;
}
else if (deltaLon > 180)
{
longitude -= 360;
}
} }
public LocationCollection(IEnumerable<Location> locations) Add(new Location(latitude, longitude));
: base(locations) }
public override string ToString()
{
return string.Join(" ", this.Select(l => l.ToString()));
}
/// <summary>
/// Creates a LocationCollection instance from a string containing a sequence
/// of Location strings that are separated by a spaces or semicolons.
/// </summary>
public static LocationCollection Parse(string locations)
{
return string.IsNullOrEmpty(locations)
? new LocationCollection()
: new LocationCollection(locations
.Split([' ', ';'], StringSplitOptions.RemoveEmptyEntries)
.Select(Location.Parse));
}
/// <summary>
/// Calculates a series of Locations on a great circle, i.e. a geodesic that connects
/// the two specified Locations, with an optional angular resolution specified in degrees.
/// See https://en.wikipedia.org/wiki/Great-circle_navigation.
/// </summary>
public static LocationCollection GeodesicLocations(Location location1, Location location2, double resolution = 1d)
{
if (resolution <= 0d)
{ {
throw new ArgumentOutOfRangeException(
nameof(resolution), $"The {nameof(resolution)} argument must be greater than zero.");
} }
public LocationCollection(params Location[] locations) var lat1 = location1.Latitude * Math.PI / 180d;
: base(locations) var lon1 = location1.Longitude * Math.PI / 180d;
var lat2 = location2.Latitude * Math.PI / 180d;
var lon2 = location2.Longitude * Math.PI / 180d;
var cosLat1 = Math.Cos(lat1);
var sinLat1 = Math.Sin(lat1);
var cosLat2 = Math.Cos(lat2);
var sinLat2 = Math.Sin(lat2);
var cosLon12 = Math.Cos(lon2 - lon1);
var sinLon12 = Math.Sin(lon2 - lon1);
var a = cosLat2 * sinLon12;
var b = cosLat1 * sinLat2 - sinLat1 * cosLat2 * cosLon12;
// σ12
var s12 = Math.Atan2(Math.Sqrt(a * a + b * b), sinLat1 * sinLat2 + cosLat1 * cosLat2 * cosLon12);
var n = (int)Math.Ceiling(s12 / resolution * 180d / Math.PI); // s12 in radians
var locations = new LocationCollection(new Location(location1.Latitude, location1.Longitude));
if (n > 1)
{ {
// https://en.wikipedia.org/wiki/Great-circle_navigation#Finding_way-points
// α1
var az1 = Math.Atan2(a, b);
var cosAz1 = Math.Cos(az1);
var sinAz1 = Math.Sin(az1);
// α0
var az0 = Math.Atan2(sinAz1 * cosLat1, Math.Sqrt(cosAz1 * cosAz1 + sinAz1 * sinAz1 * sinLat1 * sinLat1));
var cosAz0 = Math.Cos(az0);
var sinAz0 = Math.Sin(az0);
// σ01
var s01 = Math.Atan2(sinLat1, cosLat1 * cosAz1);
// λ0
var lon0 = lon1 - Math.Atan2(sinAz0 * Math.Sin(s01), Math.Cos(s01));
for (var i = 1; i < n; i++)
{
var s = s01 + i * s12 / n;
var sinS = Math.Sin(s);
var cosS = Math.Cos(s);
var lat = Math.Atan2(cosAz0 * sinS, Math.Sqrt(cosS * cosS + sinAz0 * sinAz0 * sinS * sinS));
var lon = Math.Atan2(sinAz0 * sinS, cosS) + lon0;
locations.Add(lat * 180d / Math.PI, lon * 180d / Math.PI);
}
} }
public void Add(double latitude, double longitude) locations.Add(location2.Latitude, location2.Longitude);
return locations;
}
/// <summary>
/// Calculates a series of Locations on a rhumb line that connects the two
/// specified Locations, with an optional angular resolution specified in degrees.
/// See https://en.wikipedia.org/wiki/Rhumb_line.
/// </summary>
public static LocationCollection RhumblineLocations(Location location1, Location location2, double resolution = 1d)
{
if (resolution <= 0d)
{ {
if (Count > 0) throw new ArgumentOutOfRangeException(
{ nameof(resolution), $"The {nameof(resolution)} argument must be greater than zero.");
var deltaLon = longitude - this[Count - 1].Longitude;
if (deltaLon < -180d)
{
longitude += 360d;
}
else if (deltaLon > 180)
{
longitude -= 360;
}
}
Add(new Location(latitude, longitude));
} }
public override string ToString() var lat1 = location1.Latitude;
var lon1 = location1.Longitude;
var lat2 = location2.Latitude;
var lon2 = location2.Longitude;
var y1 = WebMercatorProjection.LatitudeToY(lat1);
var y2 = WebMercatorProjection.LatitudeToY(lat2);
if (double.IsInfinity(y1))
{ {
return string.Join(" ", this.Select(l => l.ToString())); throw new ArgumentOutOfRangeException(
nameof(location1), $"The {nameof(location1)} argument must have an absolute latitude value of less than 90.");
} }
/// <summary> if (double.IsInfinity(y2))
/// Creates a LocationCollection instance from a string containing a sequence
/// of Location strings that are separated by a spaces or semicolons.
/// </summary>
public static LocationCollection Parse(string locations)
{ {
return string.IsNullOrEmpty(locations) throw new ArgumentOutOfRangeException(
? new LocationCollection() nameof(location2), $"The {nameof(location2)} argument must have an absolute latitude value of less than 90.");
: new LocationCollection(locations
.Split([' ', ';'], StringSplitOptions.RemoveEmptyEntries)
.Select(Location.Parse));
} }
/// <summary> var dlat = lat2 - lat1;
/// Calculates a series of Locations on a great circle, i.e. a geodesic that connects var dlon = lon2 - lon1;
/// the two specified Locations, with an optional angular resolution specified in degrees. var dy = y2 - y1;
/// See https://en.wikipedia.org/wiki/Great-circle_navigation.
/// </summary> // beta = atan(dlon,dy)
public static LocationCollection GeodesicLocations(Location location1, Location location2, double resolution = 1d) // sec(beta) = 1 / cos(atan(dlon,dy)) = sqrt(1 + (dlon/dy)^2)
//
var sec = Math.Sqrt(1d + dlon * dlon / (dy * dy));
const double secLimit = 1000d; // beta approximately +/-90°
double s12;
if (sec > secLimit)
{ {
if (resolution <= 0d) var lat = (lat1 + lat2) * Math.PI / 360d; // mean latitude
{
throw new ArgumentOutOfRangeException(
nameof(resolution), $"The {nameof(resolution)} argument must be greater than zero.");
}
var lat1 = location1.Latitude * Math.PI / 180d; s12 = Math.Abs(dlon * Math.Cos(lat)); // distance in degrees along parallel of latitude
var lon1 = location1.Longitude * Math.PI / 180d; }
var lat2 = location2.Latitude * Math.PI / 180d; else
var lon2 = location2.Longitude * Math.PI / 180d; {
var cosLat1 = Math.Cos(lat1); s12 = Math.Abs(dlat * sec); // distance in degrees along loxodrome
var sinLat1 = Math.Sin(lat1);
var cosLat2 = Math.Cos(lat2);
var sinLat2 = Math.Sin(lat2);
var cosLon12 = Math.Cos(lon2 - lon1);
var sinLon12 = Math.Sin(lon2 - lon1);
var a = cosLat2 * sinLon12;
var b = cosLat1 * sinLat2 - sinLat1 * cosLat2 * cosLon12;
// σ12
var s12 = Math.Atan2(Math.Sqrt(a * a + b * b), sinLat1 * sinLat2 + cosLat1 * cosLat2 * cosLon12);
var n = (int)Math.Ceiling(s12 / resolution * 180d / Math.PI); // s12 in radians
var locations = new LocationCollection(new Location(location1.Latitude, location1.Longitude));
if (n > 1)
{
// https://en.wikipedia.org/wiki/Great-circle_navigation#Finding_way-points
// α1
var az1 = Math.Atan2(a, b);
var cosAz1 = Math.Cos(az1);
var sinAz1 = Math.Sin(az1);
// α0
var az0 = Math.Atan2(sinAz1 * cosLat1, Math.Sqrt(cosAz1 * cosAz1 + sinAz1 * sinAz1 * sinLat1 * sinLat1));
var cosAz0 = Math.Cos(az0);
var sinAz0 = Math.Sin(az0);
// σ01
var s01 = Math.Atan2(sinLat1, cosLat1 * cosAz1);
// λ0
var lon0 = lon1 - Math.Atan2(sinAz0 * Math.Sin(s01), Math.Cos(s01));
for (var i = 1; i < n; i++)
{
var s = s01 + i * s12 / n;
var sinS = Math.Sin(s);
var cosS = Math.Cos(s);
var lat = Math.Atan2(cosAz0 * sinS, Math.Sqrt(cosS * cosS + sinAz0 * sinAz0 * sinS * sinS));
var lon = Math.Atan2(sinAz0 * sinS, cosS) + lon0;
locations.Add(lat * 180d / Math.PI, lon * 180d / Math.PI);
}
}
locations.Add(location2.Latitude, location2.Longitude);
return locations;
} }
/// <summary> var n = (int)Math.Ceiling(s12 / resolution);
/// Calculates a series of Locations on a rhumb line that connects the two
/// specified Locations, with an optional angular resolution specified in degrees. var locations = new LocationCollection(new Location(lat1, lon1));
/// See https://en.wikipedia.org/wiki/Rhumb_line.
/// </summary> if (sec > secLimit)
public static LocationCollection RhumblineLocations(Location location1, Location location2, double resolution = 1d)
{ {
if (resolution <= 0d) for (var i = 1; i < n; i++)
{ {
throw new ArgumentOutOfRangeException( var lon = lon1 + i * dlon / n;
nameof(resolution), $"The {nameof(resolution)} argument must be greater than zero."); var lat = WebMercatorProjection.YToLatitude(y1 + i * dy / n);
locations.Add(lat, lon);
} }
var lat1 = location1.Latitude;
var lon1 = location1.Longitude;
var lat2 = location2.Latitude;
var lon2 = location2.Longitude;
var y1 = WebMercatorProjection.LatitudeToY(lat1);
var y2 = WebMercatorProjection.LatitudeToY(lat2);
if (double.IsInfinity(y1))
{
throw new ArgumentOutOfRangeException(
nameof(location1), $"The {nameof(location1)} argument must have an absolute latitude value of less than 90.");
}
if (double.IsInfinity(y2))
{
throw new ArgumentOutOfRangeException(
nameof(location2), $"The {nameof(location2)} argument must have an absolute latitude value of less than 90.");
}
var dlat = lat2 - lat1;
var dlon = lon2 - lon1;
var dy = y2 - y1;
// beta = atan(dlon,dy)
// sec(beta) = 1 / cos(atan(dlon,dy)) = sqrt(1 + (dlon/dy)^2)
//
var sec = Math.Sqrt(1d + dlon * dlon / (dy * dy));
const double secLimit = 1000d; // beta approximately +/-90°
double s12;
if (sec > secLimit)
{
var lat = (lat1 + lat2) * Math.PI / 360d; // mean latitude
s12 = Math.Abs(dlon * Math.Cos(lat)); // distance in degrees along parallel of latitude
}
else
{
s12 = Math.Abs(dlat * sec); // distance in degrees along loxodrome
}
var n = (int)Math.Ceiling(s12 / resolution);
var locations = new LocationCollection(new Location(lat1, lon1));
if (sec > secLimit)
{
for (var i = 1; i < n; i++)
{
var lon = lon1 + i * dlon / n;
var lat = WebMercatorProjection.YToLatitude(y1 + i * dy / n);
locations.Add(lat, lon);
}
}
else
{
for (var i = 1; i < n; i++)
{
var lat = lat1 + i * dlat / n;
var lon = lon1 + dlon * (WebMercatorProjection.LatitudeToY(lat) - y1) / dy;
locations.Add(lat, lon);
}
}
locations.Add(lat2, lon2);
return locations;
} }
else
{
for (var i = 1; i < n; i++)
{
var lat = lat1 + i * dlat / n;
var lon = lon1 + dlon * (WebMercatorProjection.LatitudeToY(lat) - y1) / dy;
locations.Add(lat, lon);
}
}
locations.Add(lat2, lon2);
return locations;
} }
} }

View file

@ -9,54 +9,53 @@ using Microsoft.UI.Xaml;
using Avalonia; using Avalonia;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// MapBase with default input event handling.
/// </summary>
public partial class Map : MapBase
{ {
public static readonly DependencyProperty MouseWheelZoomDeltaProperty =
DependencyPropertyHelper.Register<Map, double>(nameof(MouseWheelZoomDelta), 0.25);
public static readonly DependencyProperty MouseWheelZoomAnimatedProperty =
DependencyPropertyHelper.Register<Map, bool>(nameof(MouseWheelZoomAnimated), true);
/// <summary> /// <summary>
/// MapBase with default input event handling. /// Gets or sets the amount by which the ZoomLevel property changes by a MouseWheel event.
/// The default value is 0.25.
/// </summary> /// </summary>
public partial class Map : MapBase public double MouseWheelZoomDelta
{ {
public static readonly DependencyProperty MouseWheelZoomDeltaProperty = get => (double)GetValue(MouseWheelZoomDeltaProperty);
DependencyPropertyHelper.Register<Map, double>(nameof(MouseWheelZoomDelta), 0.25); set => SetValue(MouseWheelZoomDeltaProperty, value);
}
public static readonly DependencyProperty MouseWheelZoomAnimatedProperty = /// <summary>
DependencyPropertyHelper.Register<Map, bool>(nameof(MouseWheelZoomAnimated), true); /// Gets or sets a value that specifies whether zooming by a MouseWheel event is animated.
/// The default value is true.
/// </summary>
public bool MouseWheelZoomAnimated
{
get => (bool)GetValue(MouseWheelZoomAnimatedProperty);
set => SetValue(MouseWheelZoomAnimatedProperty, value);
}
/// <summary> private void OnMouseWheel(Point position, double delta)
/// Gets or sets the amount by which the ZoomLevel property changes by a MouseWheel event. {
/// The default value is 0.25. var zoomLevel = TargetZoomLevel + MouseWheelZoomDelta * delta;
/// </summary> var animated = false;
public double MouseWheelZoomDelta
if (delta <= -1d || delta >= 1d)
{ {
get => (double)GetValue(MouseWheelZoomDeltaProperty); // Zoom to integer multiple of MouseWheelZoomDelta when the event was raised by a
set => SetValue(MouseWheelZoomDeltaProperty, value); // mouse wheel or by a large movement on a touch pad or other high resolution device.
//
zoomLevel = MouseWheelZoomDelta * Math.Round(zoomLevel / MouseWheelZoomDelta);
animated = MouseWheelZoomAnimated;
} }
/// <summary> ZoomMap(position, zoomLevel, animated);
/// Gets or sets a value that specifies whether zooming by a MouseWheel event is animated.
/// The default value is true.
/// </summary>
public bool MouseWheelZoomAnimated
{
get => (bool)GetValue(MouseWheelZoomAnimatedProperty);
set => SetValue(MouseWheelZoomAnimatedProperty, value);
}
private void OnMouseWheel(Point position, double delta)
{
var zoomLevel = TargetZoomLevel + MouseWheelZoomDelta * delta;
var animated = false;
if (delta <= -1d || delta >= 1d)
{
// Zoom to integer multiple of MouseWheelZoomDelta when the event was raised by a
// mouse wheel or by a large movement on a touch pad or other high resolution device.
//
zoomLevel = MouseWheelZoomDelta * Math.Round(zoomLevel / MouseWheelZoomDelta);
animated = MouseWheelZoomAnimated;
}
ZoomMap(position, zoomLevel, animated);
}
} }
} }

View file

@ -17,240 +17,239 @@ using Avalonia.Controls;
using Brush = Avalonia.Media.IBrush; using Brush = Avalonia.Media.IBrush;
#endif #endif
namespace MapControl namespace MapControl;
public interface IMapLayer : IMapElement
{ {
public interface IMapLayer : IMapElement Brush MapBackground { get; }
Brush MapForeground { get; }
}
public partial class MapBase
{
public static readonly DependencyProperty MapLayerProperty =
DependencyPropertyHelper.Register<MapBase, object>(nameof(MapLayer), null,
(map, oldValue, newValue) => map.MapLayerPropertyChanged(oldValue, newValue));
public static readonly DependencyProperty MapLayersSourceProperty =
DependencyPropertyHelper.Register<MapBase, IEnumerable>(nameof(MapLayersSource), null,
(map, oldValue, newValue) => map.MapLayersSourcePropertyChanged(oldValue, newValue));
/// <summary>
/// Gets or sets the base map layer, which is added as first element to the Children collection.
/// If the passed object is not a FrameworkElement, MapBase tries to locate a DataTemplate
/// resource for the object's type and generate a FrameworkElement from that DataTemplate.
/// If the FrameworkElement implements IMapLayer (like e.g. TilePyramidLayer or MapImageLayer),
/// its (non-null) MapBackground and MapForeground property values are used for the MapBase
/// Background and Foreground.
/// </summary>
public object MapLayer
{ {
Brush MapBackground { get; } get => GetValue(MapLayerProperty);
Brush MapForeground { get; } set => SetValue(MapLayerProperty, value);
} }
public partial class MapBase /// <summary>
/// Holds a collection of map layers, either FrameworkElements or plain objects with
/// an associated DataTemplate resource from which a FrameworkElement can be created.
/// FrameworkElemens are added to the Children collection, starting at index 0.
/// The first element of this collection is assigned to the MapLayer property.
/// Subsequent changes of the MapLayer or Children properties are not reflected
/// by the MapLayersSource collection.
/// </summary>
public IEnumerable MapLayersSource
{ {
public static readonly DependencyProperty MapLayerProperty = get => (IEnumerable)GetValue(MapLayersSourceProperty);
DependencyPropertyHelper.Register<MapBase, object>(nameof(MapLayer), null, set => SetValue(MapLayersSourceProperty, value);
(map, oldValue, newValue) => map.MapLayerPropertyChanged(oldValue, newValue)); }
public static readonly DependencyProperty MapLayersSourceProperty = private void MapLayerPropertyChanged(object oldLayer, object newLayer)
DependencyPropertyHelper.Register<MapBase, IEnumerable>(nameof(MapLayersSource), null, {
(map, oldValue, newValue) => map.MapLayersSourcePropertyChanged(oldValue, newValue)); bool IsMapLayer(object layer) => Children.Count > 0 &&
(Children[0] == layer as FrameworkElement ||
((FrameworkElement)Children[0]).DataContext == layer);
/// <summary> if (oldLayer != null && IsMapLayer(oldLayer))
/// Gets or sets the base map layer, which is added as first element to the Children collection.
/// If the passed object is not a FrameworkElement, MapBase tries to locate a DataTemplate
/// resource for the object's type and generate a FrameworkElement from that DataTemplate.
/// If the FrameworkElement implements IMapLayer (like e.g. TilePyramidLayer or MapImageLayer),
/// its (non-null) MapBackground and MapForeground property values are used for the MapBase
/// Background and Foreground.
/// </summary>
public object MapLayer
{ {
get => GetValue(MapLayerProperty); RemoveChildElement(0);
set => SetValue(MapLayerProperty, value);
} }
/// <summary> if (newLayer != null && !IsMapLayer(newLayer))
/// Holds a collection of map layers, either FrameworkElements or plain objects with
/// an associated DataTemplate resource from which a FrameworkElement can be created.
/// FrameworkElemens are added to the Children collection, starting at index 0.
/// The first element of this collection is assigned to the MapLayer property.
/// Subsequent changes of the MapLayer or Children properties are not reflected
/// by the MapLayersSource collection.
/// </summary>
public IEnumerable MapLayersSource
{ {
get => (IEnumerable)GetValue(MapLayersSourceProperty); InsertChildElement(0, GetMapLayer(newLayer));
set => SetValue(MapLayersSourceProperty, value); }
}
private void MapLayersSourcePropertyChanged(IEnumerable oldLayers, IEnumerable newLayers)
{
if (oldLayers != null)
{
if (oldLayers is INotifyCollectionChanged incc)
{
incc.CollectionChanged -= MapLayersSourceCollectionChanged;
}
RemoveMapLayers(oldLayers, 0);
} }
private void MapLayerPropertyChanged(object oldLayer, object newLayer) if (newLayers != null)
{ {
bool IsMapLayer(object layer) => Children.Count > 0 && if (newLayers is INotifyCollectionChanged incc)
(Children[0] == layer as FrameworkElement ||
((FrameworkElement)Children[0]).DataContext == layer);
if (oldLayer != null && IsMapLayer(oldLayer))
{ {
RemoveChildElement(0); incc.CollectionChanged += MapLayersSourceCollectionChanged;
} }
if (newLayer != null && !IsMapLayer(newLayer)) AddMapLayers(newLayers, 0);
{
InsertChildElement(0, GetMapLayer(newLayer));
}
} }
}
private void MapLayersSourcePropertyChanged(IEnumerable oldLayers, IEnumerable newLayers) private void MapLayersSourceCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{ {
if (oldLayers != null) case NotifyCollectionChangedAction.Add:
{ AddMapLayers(e.NewItems, e.NewStartingIndex);
if (oldLayers is INotifyCollectionChanged incc) break;
{
incc.CollectionChanged -= MapLayersSourceCollectionChanged;
}
RemoveMapLayers(oldLayers, 0); case NotifyCollectionChangedAction.Remove:
} RemoveMapLayers(e.OldItems, e.OldStartingIndex);
break;
if (newLayers != null) case NotifyCollectionChangedAction.Replace:
{ RemoveMapLayers(e.OldItems, e.OldStartingIndex);
if (newLayers is INotifyCollectionChanged incc) AddMapLayers(e.NewItems, e.NewStartingIndex);
{ break;
incc.CollectionChanged += MapLayersSourceCollectionChanged;
}
AddMapLayers(newLayers, 0); case NotifyCollectionChangedAction.Reset:
} break;
default:
break;
} }
}
private void MapLayersSourceCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) private void AddMapLayers(IEnumerable layers, int index)
{
var mapLayers = layers.Cast<object>().Select(GetMapLayer).ToList();
if (mapLayers.Count > 0)
{ {
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
AddMapLayers(e.NewItems, e.NewStartingIndex);
break;
case NotifyCollectionChangedAction.Remove:
RemoveMapLayers(e.OldItems, e.OldStartingIndex);
break;
case NotifyCollectionChangedAction.Replace:
RemoveMapLayers(e.OldItems, e.OldStartingIndex);
AddMapLayers(e.NewItems, e.NewStartingIndex);
break;
case NotifyCollectionChangedAction.Reset:
break;
default:
break;
}
}
private void AddMapLayers(IEnumerable layers, int index)
{
var mapLayers = layers.Cast<object>().Select(GetMapLayer).ToList();
if (mapLayers.Count > 0)
{
#if WPF // Execute at DispatcherPriority.DataBind to ensure that all bindings are evaluated. #if WPF // Execute at DispatcherPriority.DataBind to ensure that all bindings are evaluated.
// //
Dispatcher.Invoke(() => AddMapLayers(mapLayers, index), DispatcherPriority.DataBind); Dispatcher.Invoke(() => AddMapLayers(mapLayers, index), DispatcherPriority.DataBind);
#else #else
AddMapLayers(mapLayers, index); AddMapLayers(mapLayers, index);
#endif #endif
}
}
private void AddMapLayers(List<FrameworkElement> mapLayers, int index)
{
foreach (var mapLayer in mapLayers)
{
InsertChildElement(index, mapLayer);
if (index++ == 0)
{
MapLayer = mapLayer;
}
}
}
private void RemoveMapLayers(IEnumerable layers, int index)
{
foreach (var _ in layers)
{
RemoveChildElement(index);
}
if (index == 0)
{
MapLayer = null;
}
}
private void InsertChildElement(int index, FrameworkElement element)
{
if (index == 0 && element is IMapLayer mapLayer)
{
if (mapLayer.MapBackground != null)
{
Background = mapLayer.MapBackground;
}
if (mapLayer.MapForeground != null)
{
Foreground = mapLayer.MapForeground;
} }
} }
private void AddMapLayers(List<FrameworkElement> mapLayers, int index) Children.Insert(index, element);
{ }
foreach (var mapLayer in mapLayers)
{
InsertChildElement(index, mapLayer);
if (index++ == 0) private void RemoveChildElement(int index)
{ {
MapLayer = mapLayer; if (index == 0 && Children[0] is IMapLayer mapLayer)
} {
if (mapLayer.MapBackground != null)
{
ClearValue(BackgroundProperty);
}
if (mapLayer.MapForeground != null)
{
ClearValue(ForegroundProperty);
} }
} }
private void RemoveMapLayers(IEnumerable layers, int index) Children.RemoveAt(index);
{ }
foreach (var _ in layers)
{
RemoveChildElement(index);
}
if (index == 0) private FrameworkElement GetMapLayer(object layer)
{ {
MapLayer = null; FrameworkElement mapLayer = null;
}
if (layer != null)
{
mapLayer = layer as FrameworkElement ?? TryLoadDataTemplate(layer);
} }
private void InsertChildElement(int index, FrameworkElement element) return mapLayer ?? new MapTileLayer();
{ }
if (index == 0 && element is IMapLayer mapLayer)
{
if (mapLayer.MapBackground != null)
{
Background = mapLayer.MapBackground;
}
if (mapLayer.MapForeground != null) private FrameworkElement TryLoadDataTemplate(object layer)
{ {
Foreground = mapLayer.MapForeground; FrameworkElement element = null;
}
}
Children.Insert(index, element);
}
private void RemoveChildElement(int index)
{
if (index == 0 && Children[0] is IMapLayer mapLayer)
{
if (mapLayer.MapBackground != null)
{
ClearValue(BackgroundProperty);
}
if (mapLayer.MapForeground != null)
{
ClearValue(ForegroundProperty);
}
}
Children.RemoveAt(index);
}
private FrameworkElement GetMapLayer(object layer)
{
FrameworkElement mapLayer = null;
if (layer != null)
{
mapLayer = layer as FrameworkElement ?? TryLoadDataTemplate(layer);
}
return mapLayer ?? new MapTileLayer();
}
private FrameworkElement TryLoadDataTemplate(object layer)
{
FrameworkElement element = null;
#if AVALONIA #if AVALONIA
if (this.TryFindResource(layer.GetType().FullName, out object value) && if (this.TryFindResource(layer.GetType().FullName, out object value) &&
value is Avalonia.Markup.Xaml.Templates.DataTemplate template) value is Avalonia.Markup.Xaml.Templates.DataTemplate template)
{ {
element = template.Build(layer); element = template.Build(layer);
}
#elif WPF
if (TryFindResource(new DataTemplateKey(layer.GetType())) is DataTemplate template)
{
element = (FrameworkElement)template.LoadContent();
}
#else
if (TryFindResource(this, layer.GetType().FullName) is DataTemplate template)
{
element = (FrameworkElement)template.LoadContent();
}
#endif
element?.DataContext = layer;
return element;
} }
#elif WPF
if (TryFindResource(new DataTemplateKey(layer.GetType())) is DataTemplate template)
{
element = (FrameworkElement)template.LoadContent();
}
#else
if (TryFindResource(this, layer.GetType().FullName) is DataTemplate template)
{
element = (FrameworkElement)template.LoadContent();
}
#endif
element?.DataContext = layer;
return element;
}
#if UWP || WINUI #if UWP || WINUI
private static object TryFindResource(FrameworkElement element, object key) private static object TryFindResource(FrameworkElement element, object key)
{ {
return element.Resources.ContainsKey(key) return element.Resources.ContainsKey(key)
? element.Resources[key] ? element.Resources[key]
: element.Parent is FrameworkElement parent : element.Parent is FrameworkElement parent
? TryFindResource(parent, key) ? TryFindResource(parent, key)
: null; : null;
}
#endif
} }
#endif
} }

View file

@ -13,431 +13,430 @@ using Avalonia;
using Brush = Avalonia.Media.IBrush; using Brush = Avalonia.Media.IBrush;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// The map control. Displays map content provided by one or more layers like MapTileLayer,
/// WmtsTileLayer or WmsImageLayer. The visible map area is defined by the Center and
/// ZoomLevel properties. The map can be rotated by an angle provided by the Heading property.
/// MapBase can contain map overlay child elements like other MapPanels or MapItemsControls.
/// </summary>
public partial class MapBase : MapPanel
{ {
/// <summary> public static double ZoomLevelToScale(double zoomLevel)
/// The map control. Displays map content provided by one or more layers like MapTileLayer,
/// WmtsTileLayer or WmsImageLayer. The visible map area is defined by the Center and
/// ZoomLevel properties. The map can be rotated by an angle provided by the Heading property.
/// MapBase can contain map overlay child elements like other MapPanels or MapItemsControls.
/// </summary>
public partial class MapBase : MapPanel
{ {
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);
}
public static TimeSpan ImageFadeDuration { get; set; } = TimeSpan.FromSeconds(0.2);
public static readonly DependencyProperty AnimationDurationProperty =
DependencyPropertyHelper.Register<MapBase, TimeSpan>(nameof(AnimationDuration), TimeSpan.FromSeconds(0.3));
public static readonly DependencyProperty MapProjectionProperty =
DependencyPropertyHelper.Register<MapBase, MapProjection>(nameof(MapProjection), new WebMercatorProjection(),
(map, oldValue, newValue) => map.MapProjectionPropertyChanged(newValue));
private Location transformCenter;
private Point viewCenter;
private double centerLongitude;
private double maxLatitude = 85.05112878; // default WebMercatorProjection
private bool internalPropertyChange;
/// <summary>
/// Raised when the current map viewport has changed.
/// </summary>
public event EventHandler<ViewportChangedEventArgs> ViewportChanged;
/// <summary>
/// Gets or sets the map foreground Brush.
/// </summary>
public Brush Foreground
{
get => (Brush)GetValue(ForegroundProperty);
set => SetValue(ForegroundProperty, value);
}
/// <summary>
/// Gets or sets the duration of the Center, ZoomLevel and Heading animations.
/// The default value is 0.3 seconds.
/// </summary>
public TimeSpan AnimationDuration
{
get => (TimeSpan)GetValue(AnimationDurationProperty);
set => SetValue(AnimationDurationProperty, value);
}
/// <summary>
/// Gets or sets the MapProjection used by the map control.
/// </summary>
public MapProjection MapProjection
{
get => (MapProjection)GetValue(MapProjectionProperty);
set => SetValue(MapProjectionProperty, value);
}
/// <summary>
/// Gets or sets the location of the center point of the map.
/// </summary>
public Location Center
{
get => (Location)GetValue(CenterProperty);
set => SetValue(CenterProperty, value);
}
/// <summary>
/// Gets or sets the target value of a Center animation.
/// </summary>
public Location TargetCenter
{
get => (Location)GetValue(TargetCenterProperty);
set => SetValue(TargetCenterProperty, value);
}
/// <summary>
/// Gets or sets the minimum value of the ZoomLevel and TargetZoomLevel properties.
/// Must not be less than zero or greater than MaxZoomLevel. The default value is 1.
/// </summary>
public double MinZoomLevel
{
get => (double)GetValue(MinZoomLevelProperty);
set => SetValue(MinZoomLevelProperty, value);
}
/// <summary>
/// Gets or sets the maximum value of the ZoomLevel and TargetZoomLevel properties.
/// Must not be less than MinZoomLevel. The default value is 20.
/// </summary>
public double MaxZoomLevel
{
get => (double)GetValue(MaxZoomLevelProperty);
set => SetValue(MaxZoomLevelProperty, value);
}
/// <summary>
/// Gets or sets the map zoom level.
/// </summary>
public double ZoomLevel
{
get => (double)GetValue(ZoomLevelProperty);
set => SetValue(ZoomLevelProperty, value);
}
/// <summary>
/// Gets or sets the target value of a ZoomLevel animation.
/// </summary>
public double TargetZoomLevel
{
get => (double)GetValue(TargetZoomLevelProperty);
set => SetValue(TargetZoomLevelProperty, value);
}
/// <summary>
/// Gets or sets the map heading, a counter-clockwise rotation angle in degrees.
/// </summary>
public double Heading
{
get => (double)GetValue(HeadingProperty);
set => SetValue(HeadingProperty, value);
}
/// <summary>
/// Gets or sets the target value of a Heading animation.
/// </summary>
public double TargetHeading
{
get => (double)GetValue(TargetHeadingProperty);
set => SetValue(TargetHeadingProperty, value);
}
/// <summary>
/// Gets the ViewTransform instance that is used to transform between
/// projected map coordinates and view coordinates.
/// </summary>
public ViewTransform ViewTransform { get; } = new ViewTransform();
/// <summary>
/// Gets a transform Matrix from meters to view coordinates for scaling and rotating
/// at the specified geographic coordinates.
/// </summary>
public Matrix GetMapToViewTransform(double latitude, double longitude)
{
var transform = MapProjection.RelativeTransform(latitude, longitude);
transform.Scale(ViewTransform.Scale, ViewTransform.Scale);
transform.Rotate(ViewTransform.Rotation);
return transform;
}
/// <summary>
/// Gets a transform Matrix from meters to view coordinates for scaling and rotating
/// at the specified Location.
/// </summary>
public Matrix GetMapToViewTransform(Location location) => GetMapToViewTransform(location.Latitude, location.Longitude);
/// <summary>
/// Transforms geographic coordinates to a Point in view coordinates.
/// </summary>
public Point LocationToView(double latitude, double longitude) => ViewTransform.MapToView(MapProjection.LocationToMap(latitude, longitude));
/// <summary>
/// Transforms a Location in geographic coordinates to a Point in view coordinates.
/// </summary>
public Point LocationToView(Location location) => LocationToView(location.Latitude, location.Longitude);
/// <summary>
/// Transforms a Point in view coordinates to a Location in geographic coordinates.
/// </summary>
public Location ViewToLocation(Point point) => MapProjection.MapToLocation(ViewTransform.ViewToMap(point));
/// <summary>
/// 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.
/// </summary>
public void SetTransformCenter(Point center)
{
viewCenter = center;
transformCenter = ViewToLocation(center);
}
/// <summary>
/// Resets the temporary transform center point set by SetTransformCenter.
/// </summary>
public void ResetTransformCenter()
{
viewCenter = new Point(ActualWidth / 2d, ActualHeight / 2d);
transformCenter = null;
}
/// <summary>
/// Changes the Center property according to the specified translation in view coordinates.
/// </summary>
public void TranslateMap(Point translation)
{
if (translation.X != 0d || translation.Y != 0d)
{ {
return 256d * Math.Pow(2d, zoomLevel) / (360d * MapProjection.Wgs84MeterPerDegree); Center = ViewToLocation(new Point(viewCenter.X - translation.X, viewCenter.Y - translation.Y));
} }
}
public static double ScaleToZoomLevel(double scale) /// <summary>
/// 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.
/// </summary>
public void TransformMap(Point center, Point translation, double rotation, double scale)
{
if (rotation == 0d && scale == 1d)
{ {
return Math.Log(scale * 360d * MapProjection.Wgs84MeterPerDegree / 256d, 2d); TranslateMap(translation);
} }
else
public static TimeSpan ImageFadeDuration { get; set; } = TimeSpan.FromSeconds(0.2);
public static readonly DependencyProperty AnimationDurationProperty =
DependencyPropertyHelper.Register<MapBase, TimeSpan>(nameof(AnimationDuration), TimeSpan.FromSeconds(0.3));
public static readonly DependencyProperty MapProjectionProperty =
DependencyPropertyHelper.Register<MapBase, MapProjection>(nameof(MapProjection), new WebMercatorProjection(),
(map, oldValue, newValue) => map.MapProjectionPropertyChanged(newValue));
private Location transformCenter;
private Point viewCenter;
private double centerLongitude;
private double maxLatitude = 85.05112878; // default WebMercatorProjection
private bool internalPropertyChange;
/// <summary>
/// Raised when the current map viewport has changed.
/// </summary>
public event EventHandler<ViewportChangedEventArgs> ViewportChanged;
/// <summary>
/// Gets or sets the map foreground Brush.
/// </summary>
public Brush Foreground
{ {
get => (Brush)GetValue(ForegroundProperty); SetTransformCenter(center);
set => SetValue(ForegroundProperty, value); viewCenter = new Point(viewCenter.X + translation.X, viewCenter.Y + translation.Y);
}
/// <summary> if (rotation != 0d)
/// Gets or sets the duration of the Center, ZoomLevel and Heading animations.
/// The default value is 0.3 seconds.
/// </summary>
public TimeSpan AnimationDuration
{
get => (TimeSpan)GetValue(AnimationDurationProperty);
set => SetValue(AnimationDurationProperty, value);
}
/// <summary>
/// Gets or sets the MapProjection used by the map control.
/// </summary>
public MapProjection MapProjection
{
get => (MapProjection)GetValue(MapProjectionProperty);
set => SetValue(MapProjectionProperty, value);
}
/// <summary>
/// Gets or sets the location of the center point of the map.
/// </summary>
public Location Center
{
get => (Location)GetValue(CenterProperty);
set => SetValue(CenterProperty, value);
}
/// <summary>
/// Gets or sets the target value of a Center animation.
/// </summary>
public Location TargetCenter
{
get => (Location)GetValue(TargetCenterProperty);
set => SetValue(TargetCenterProperty, value);
}
/// <summary>
/// Gets or sets the minimum value of the ZoomLevel and TargetZoomLevel properties.
/// Must not be less than zero or greater than MaxZoomLevel. The default value is 1.
/// </summary>
public double MinZoomLevel
{
get => (double)GetValue(MinZoomLevelProperty);
set => SetValue(MinZoomLevelProperty, value);
}
/// <summary>
/// Gets or sets the maximum value of the ZoomLevel and TargetZoomLevel properties.
/// Must not be less than MinZoomLevel. The default value is 20.
/// </summary>
public double MaxZoomLevel
{
get => (double)GetValue(MaxZoomLevelProperty);
set => SetValue(MaxZoomLevelProperty, value);
}
/// <summary>
/// Gets or sets the map zoom level.
/// </summary>
public double ZoomLevel
{
get => (double)GetValue(ZoomLevelProperty);
set => SetValue(ZoomLevelProperty, value);
}
/// <summary>
/// Gets or sets the target value of a ZoomLevel animation.
/// </summary>
public double TargetZoomLevel
{
get => (double)GetValue(TargetZoomLevelProperty);
set => SetValue(TargetZoomLevelProperty, value);
}
/// <summary>
/// Gets or sets the map heading, a counter-clockwise rotation angle in degrees.
/// </summary>
public double Heading
{
get => (double)GetValue(HeadingProperty);
set => SetValue(HeadingProperty, value);
}
/// <summary>
/// Gets or sets the target value of a Heading animation.
/// </summary>
public double TargetHeading
{
get => (double)GetValue(TargetHeadingProperty);
set => SetValue(TargetHeadingProperty, value);
}
/// <summary>
/// Gets the ViewTransform instance that is used to transform between
/// projected map coordinates and view coordinates.
/// </summary>
public ViewTransform ViewTransform { get; } = new ViewTransform();
/// <summary>
/// Gets a transform Matrix from meters to view coordinates for scaling and rotating
/// at the specified geographic coordinates.
/// </summary>
public Matrix GetMapToViewTransform(double latitude, double longitude)
{
var transform = MapProjection.RelativeTransform(latitude, longitude);
transform.Scale(ViewTransform.Scale, ViewTransform.Scale);
transform.Rotate(ViewTransform.Rotation);
return transform;
}
/// <summary>
/// Gets a transform Matrix from meters to view coordinates for scaling and rotating
/// at the specified Location.
/// </summary>
public Matrix GetMapToViewTransform(Location location) => GetMapToViewTransform(location.Latitude, location.Longitude);
/// <summary>
/// Transforms geographic coordinates to a Point in view coordinates.
/// </summary>
public Point LocationToView(double latitude, double longitude) => ViewTransform.MapToView(MapProjection.LocationToMap(latitude, longitude));
/// <summary>
/// Transforms a Location in geographic coordinates to a Point in view coordinates.
/// </summary>
public Point LocationToView(Location location) => LocationToView(location.Latitude, location.Longitude);
/// <summary>
/// Transforms a Point in view coordinates to a Location in geographic coordinates.
/// </summary>
public Location ViewToLocation(Point point) => MapProjection.MapToLocation(ViewTransform.ViewToMap(point));
/// <summary>
/// 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.
/// </summary>
public void SetTransformCenter(Point center)
{
viewCenter = center;
transformCenter = ViewToLocation(center);
}
/// <summary>
/// Resets the temporary transform center point set by SetTransformCenter.
/// </summary>
public void ResetTransformCenter()
{
viewCenter = new Point(ActualWidth / 2d, ActualHeight / 2d);
transformCenter = null;
}
/// <summary>
/// Changes the Center property according to the specified translation in view coordinates.
/// </summary>
public void TranslateMap(Point translation)
{
if (translation.X != 0d || translation.Y != 0d)
{ {
Center = ViewToLocation(new Point(viewCenter.X - translation.X, viewCenter.Y - translation.Y)); var heading = CoerceHeadingProperty(Heading - rotation);
SetValueInternal(HeadingProperty, heading);
SetValueInternal(TargetHeadingProperty, heading);
} }
}
/// <summary> if (scale != 1d)
/// 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.
/// </summary>
public void TransformMap(Point center, Point translation, double rotation, double scale)
{
if (rotation == 0d && scale == 1d)
{ {
TranslateMap(translation); var zoomLevel = CoerceZoomLevelProperty(ZoomLevel + Math.Log(scale, 2d));
SetValueInternal(ZoomLevelProperty, zoomLevel);
SetValueInternal(TargetZoomLevelProperty, zoomLevel);
} }
else
UpdateTransform(true);
}
}
/// <summary>
/// Sets the ZoomLevel or TargetZoomLevel property while retaining
/// the specified center point in view coordinates.
/// </summary>
public void ZoomMap(Point center, double zoomLevel, bool animated = true)
{
zoomLevel = CoerceZoomLevelProperty(zoomLevel);
if (animated || zoomLevelAnimation != null)
{
if (TargetZoomLevel != zoomLevel)
{ {
SetTransformCenter(center); SetTransformCenter(center);
viewCenter = new Point(viewCenter.X + translation.X, viewCenter.Y + translation.Y); TargetZoomLevel = zoomLevel;
}
if (rotation != 0d) }
{ else
var heading = CoerceHeadingProperty(Heading - rotation); {
SetValueInternal(HeadingProperty, heading); if (ZoomLevel != zoomLevel)
SetValueInternal(TargetHeadingProperty, heading); {
} SetTransformCenter(center);
SetValueInternal(ZoomLevelProperty, zoomLevel);
if (scale != 1d) SetValueInternal(TargetZoomLevelProperty, zoomLevel);
{
var zoomLevel = CoerceZoomLevelProperty(ZoomLevel + Math.Log(scale, 2d));
SetValueInternal(ZoomLevelProperty, zoomLevel);
SetValueInternal(TargetZoomLevelProperty, zoomLevel);
}
UpdateTransform(true); UpdateTransform(true);
} }
} }
}
/// <summary> /// <summary>
/// Sets the ZoomLevel or TargetZoomLevel property while retaining /// Sets the TargetZoomLevel and TargetCenter properties so that the specified BoundingBox
/// the specified center point in view coordinates. /// fits into the current view. The TargetHeading property is set to zero.
/// </summary> /// </summary>
public void ZoomMap(Point center, double zoomLevel, bool animated = true) public void ZoomToBounds(BoundingBox bounds)
{
(var rect, var _) = MapProjection.BoundingBoxToMap(bounds);
var scale = Math.Min(ActualWidth / rect.Width, ActualHeight / rect.Height);
TargetZoomLevel = ScaleToZoomLevel(scale);
TargetCenter = new Location((bounds.South + bounds.North) / 2d, (bounds.West + bounds.East) / 2d);
TargetHeading = 0d;
}
internal bool InsideViewBounds(Point point)
{
return point.X >= 0d && point.Y >= 0d && point.X <= ActualWidth && point.Y <= ActualHeight;
}
internal double NearestLongitude(double longitude)
{
longitude = Location.NormalizeLongitude(longitude);
var offset = longitude - Center.Longitude;
if (offset > 180d)
{ {
zoomLevel = CoerceZoomLevelProperty(zoomLevel); longitude = Center.Longitude + (offset % 360d) - 360d;
}
else if (offset < -180d)
{
longitude = Center.Longitude + (offset % 360d) + 360d;
}
if (animated || zoomLevelAnimation != null) return longitude;
}
private Location CoerceCenterProperty(Location center)
{
if (center == null)
{
center = new Location(0d, 0d);
}
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);
}
private double CoerceHeadingProperty(double heading)
{
return ((heading % 360d) + 360d) % 360d;
}
private void SetValueInternal(DependencyProperty property, object value)
{
internalPropertyChange = true;
SetValue(property, value);
internalPropertyChange = false;
}
private void MapProjectionPropertyChanged(MapProjection projection)
{
maxLatitude = 90d;
if (projection.IsNormalCylindrical)
{
maxLatitude = projection.MapToLocation(0d, 180d * MapProjection.Wgs84MeterPerDegree).Latitude;
Center = CoerceCenterProperty(Center);
}
ResetTransformCenter();
UpdateTransform(false, true);
}
private void UpdateTransform(bool resetTransformCenter = false, bool projectionChanged = false)
{
var transformCenterChanged = false;
var viewScale = ZoomLevelToScale(ZoomLevel);
var mapCenter = MapProjection.LocationToMap(transformCenter ?? Center);
ViewTransform.SetTransform(mapCenter, viewCenter, viewScale, -Heading);
if (transformCenter != null)
{
var center = ViewToLocation(new Point(ActualWidth / 2d, ActualHeight / 2d));
var latitude = center.Latitude;
var longitude = Location.NormalizeLongitude(center.Longitude);
if (latitude < -maxLatitude || latitude > maxLatitude)
{ {
if (TargetZoomLevel != zoomLevel) latitude = Math.Min(Math.Max(latitude, -maxLatitude), maxLatitude);
{ resetTransformCenter = true;
SetTransformCenter(center);
TargetZoomLevel = zoomLevel;
}
} }
else
if (!center.Equals(latitude, longitude))
{ {
if (ZoomLevel != zoomLevel) center = new Location(latitude, longitude);
{ }
SetTransformCenter(center);
SetValueInternal(ZoomLevelProperty, zoomLevel); SetValueInternal(CenterProperty, center);
SetValueInternal(TargetZoomLevelProperty, zoomLevel);
UpdateTransform(true); if (centerAnimation == null)
} {
SetValueInternal(TargetCenterProperty, center);
}
if (resetTransformCenter)
{
// Check if transform center has moved across 180° longitude.
//
transformCenterChanged = Math.Abs(center.Longitude - transformCenter.Longitude) > 180d;
ResetTransformCenter();
mapCenter = MapProjection.LocationToMap(center);
ViewTransform.SetTransform(mapCenter, viewCenter, viewScale, -Heading);
} }
} }
/// <summary> ViewScale = ViewTransform.Scale;
/// Sets the TargetZoomLevel and TargetCenter properties so that the specified BoundingBox
/// fits into the current view. The TargetHeading property is set to zero.
/// </summary>
public void ZoomToBounds(BoundingBox bounds)
{
(var rect, var _) = MapProjection.BoundingBoxToMap(bounds);
var scale = Math.Min(ActualWidth / rect.Width, ActualHeight / rect.Height);
TargetZoomLevel = ScaleToZoomLevel(scale);
TargetCenter = new Location((bounds.South + bounds.North) / 2d, (bounds.West + bounds.East) / 2d);
TargetHeading = 0d;
}
internal bool InsideViewBounds(Point point) // Check if view center has moved across 180° longitude.
{ //
return point.X >= 0d && point.Y >= 0d && point.X <= ActualWidth && point.Y <= ActualHeight; transformCenterChanged = transformCenterChanged || Math.Abs(Center.Longitude - centerLongitude) > 180d;
} centerLongitude = Center.Longitude;
internal double NearestLongitude(double longitude) OnViewportChanged(new ViewportChangedEventArgs(projectionChanged, transformCenterChanged));
{ }
longitude = Location.NormalizeLongitude(longitude);
var offset = longitude - Center.Longitude;
if (offset > 180d) protected override void OnViewportChanged(ViewportChangedEventArgs e)
{ {
longitude = Center.Longitude + (offset % 360d) - 360d; base.OnViewportChanged(e);
} ViewportChanged?.Invoke(this, e);
else if (offset < -180d)
{
longitude = Center.Longitude + (offset % 360d) + 360d;
}
return longitude;
}
private Location CoerceCenterProperty(Location center)
{
if (center == null)
{
center = new Location(0d, 0d);
}
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);
}
private double CoerceHeadingProperty(double heading)
{
return ((heading % 360d) + 360d) % 360d;
}
private void SetValueInternal(DependencyProperty property, object value)
{
internalPropertyChange = true;
SetValue(property, value);
internalPropertyChange = false;
}
private void MapProjectionPropertyChanged(MapProjection projection)
{
maxLatitude = 90d;
if (projection.IsNormalCylindrical)
{
maxLatitude = projection.MapToLocation(0d, 180d * MapProjection.Wgs84MeterPerDegree).Latitude;
Center = CoerceCenterProperty(Center);
}
ResetTransformCenter();
UpdateTransform(false, true);
}
private void UpdateTransform(bool resetTransformCenter = false, bool projectionChanged = false)
{
var transformCenterChanged = false;
var viewScale = ZoomLevelToScale(ZoomLevel);
var mapCenter = MapProjection.LocationToMap(transformCenter ?? Center);
ViewTransform.SetTransform(mapCenter, viewCenter, viewScale, -Heading);
if (transformCenter != null)
{
var center = ViewToLocation(new Point(ActualWidth / 2d, ActualHeight / 2d));
var latitude = center.Latitude;
var longitude = Location.NormalizeLongitude(center.Longitude);
if (latitude < -maxLatitude || latitude > maxLatitude)
{
latitude = Math.Min(Math.Max(latitude, -maxLatitude), maxLatitude);
resetTransformCenter = true;
}
if (!center.Equals(latitude, longitude))
{
center = new Location(latitude, longitude);
}
SetValueInternal(CenterProperty, center);
if (centerAnimation == null)
{
SetValueInternal(TargetCenterProperty, center);
}
if (resetTransformCenter)
{
// Check if transform center has moved across 180° longitude.
//
transformCenterChanged = Math.Abs(center.Longitude - transformCenter.Longitude) > 180d;
ResetTransformCenter();
mapCenter = MapProjection.LocationToMap(center);
ViewTransform.SetTransform(mapCenter, viewCenter, viewScale, -Heading);
}
}
ViewScale = ViewTransform.Scale;
// 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);
}
} }
} }

View file

@ -8,84 +8,83 @@ using Microsoft.UI.Xaml;
using Avalonia; using Avalonia;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// A MapPanel that adjusts the ViewPosition property of its child elements so that
/// elements that would be outside the current viewport are arranged on a border area.
/// Such elements are arranged at a distance of BorderWidth/2 from the edges of the
/// MapBorderPanel in direction of their original azimuth from the map center.
/// </summary>
public partial class MapBorderPanel : MapPanel
{ {
/// <summary> public static readonly DependencyProperty BorderWidthProperty =
/// A MapPanel that adjusts the ViewPosition property of its child elements so that DependencyPropertyHelper.Register<MapBorderPanel, double>(nameof(BorderWidth));
/// elements that would be outside the current viewport are arranged on a border area.
/// Such elements are arranged at a distance of BorderWidth/2 from the edges of the public static readonly DependencyProperty OnBorderProperty =
/// MapBorderPanel in direction of their original azimuth from the map center. DependencyPropertyHelper.RegisterAttached<bool>("OnBorder", typeof(MapBorderPanel));
/// </summary>
public partial class MapBorderPanel : MapPanel public double BorderWidth
{ {
public static readonly DependencyProperty BorderWidthProperty = get => (double)GetValue(BorderWidthProperty);
DependencyPropertyHelper.Register<MapBorderPanel, double>(nameof(BorderWidth)); set => SetValue(BorderWidthProperty, value);
}
public static readonly DependencyProperty OnBorderProperty = public static bool GetOnBorder(FrameworkElement element)
DependencyPropertyHelper.RegisterAttached<bool>("OnBorder", typeof(MapBorderPanel)); {
return (bool)element.GetValue(OnBorderProperty);
}
public double BorderWidth protected override Point SetViewPosition(FrameworkElement element, Point position)
{
var onBorder = false;
var w = ParentMap.ActualWidth;
var h = ParentMap.ActualHeight;
var minX = BorderWidth / 2d;
var minY = BorderWidth / 2d;
var maxX = w - BorderWidth / 2d;
var maxY = h - BorderWidth / 2d;
if (position.X < minX || position.X > maxX ||
position.Y < minY || position.Y > maxY)
{ {
get => (double)GetValue(BorderWidthProperty); var dx = position.X - w / 2d;
set => SetValue(BorderWidthProperty, value); var dy = position.Y - h / 2d;
} var cx = (maxX - minX) / 2d;
var cy = (maxY - minY) / 2d;
double x, y;
public static bool GetOnBorder(FrameworkElement element) if (dx < 0d)
{
return (bool)element.GetValue(OnBorderProperty);
}
protected override Point SetViewPosition(FrameworkElement element, Point position)
{
var onBorder = false;
var w = ParentMap.ActualWidth;
var h = ParentMap.ActualHeight;
var minX = BorderWidth / 2d;
var minY = BorderWidth / 2d;
var maxX = w - BorderWidth / 2d;
var maxY = h - BorderWidth / 2d;
if (position.X < minX || position.X > maxX ||
position.Y < minY || position.Y > maxY)
{ {
var dx = position.X - w / 2d; x = minX;
var dy = position.Y - h / 2d; y = minY + cy - cx * dy / dx;
var cx = (maxX - minX) / 2d; }
var cy = (maxY - minY) / 2d; else
double x, y; {
x = maxX;
y = minY + cy + cx * dy / dx;
}
if (dx < 0d) if (y < minY || y > maxY)
{
if (dy < 0d)
{ {
x = minX; x = minX + cx - cy * dx / dy;
y = minY + cy - cx * dy / dx; y = minY;
} }
else else
{ {
x = maxX; x = minX + cx + cy * dx / dy;
y = minY + cy + cx * dy / dx; y = maxY;
} }
if (y < minY || y > maxY)
{
if (dy < 0d)
{
x = minX + cx - cy * dx / dy;
y = minY;
}
else
{
x = minX + cx + cy * dx / dy;
y = maxY;
}
}
position = new Point(x, y);
onBorder = true;
} }
element.SetValue(OnBorderProperty, onBorder); position = new Point(x, y);
onBorder = true;
return base.SetViewPosition(element, position);
} }
element.SetValue(OnBorderProperty, onBorder);
return base.SetViewPosition(element, position);
} }
} }

View file

@ -11,44 +11,43 @@ using Microsoft.UI.Xaml.Controls;
using Avalonia.Controls; using Avalonia.Controls;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// ContentControl placed on a MapPanel at a geographic location specified by the Location property.
/// </summary>
public partial class MapContentControl : ContentControl
{ {
public static readonly DependencyProperty LocationProperty =
DependencyPropertyHelper.AddOwner<MapContentControl, Location>(
nameof(Location), MapPanel.LocationProperty);
public static readonly DependencyProperty AutoCollapseProperty =
DependencyPropertyHelper.AddOwner<MapContentControl, bool>(
nameof(AutoCollapse), MapPanel.AutoCollapseProperty);
/// <summary> /// <summary>
/// ContentControl placed on a MapPanel at a geographic location specified by the Location property. /// Gets/sets MapPanel.Location.
/// </summary> /// </summary>
public partial class MapContentControl : ContentControl public Location Location
{ {
public static readonly DependencyProperty LocationProperty = get => (Location)GetValue(LocationProperty);
DependencyPropertyHelper.AddOwner<MapContentControl, Location>( set => SetValue(LocationProperty, value);
nameof(Location), MapPanel.LocationProperty);
public static readonly DependencyProperty AutoCollapseProperty =
DependencyPropertyHelper.AddOwner<MapContentControl, bool>(
nameof(AutoCollapse), MapPanel.AutoCollapseProperty);
/// <summary>
/// Gets/sets MapPanel.Location.
/// </summary>
public Location Location
{
get => (Location)GetValue(LocationProperty);
set => SetValue(LocationProperty, value);
}
/// <summary>
/// Gets/sets MapPanel.AutoCollapse.
/// </summary>
public bool AutoCollapse
{
get => (bool)GetValue(AutoCollapseProperty);
set => SetValue(AutoCollapseProperty, value);
}
} }
/// <summary> /// <summary>
/// MapContentControl with a Pushpin Style. /// Gets/sets MapPanel.AutoCollapse.
/// </summary> /// </summary>
public partial class Pushpin : MapContentControl public bool AutoCollapse
{ {
get => (bool)GetValue(AutoCollapseProperty);
set => SetValue(AutoCollapseProperty, value);
} }
} }
/// <summary>
/// MapContentControl with a Pushpin Style.
/// </summary>
public partial class Pushpin : MapContentControl
{
}

View file

@ -17,254 +17,253 @@ using Avalonia.Layout;
using PathFigureCollection = Avalonia.Media.PathFigures; using PathFigureCollection = Avalonia.Media.PathFigures;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Draws a map graticule, i.e. a lat/lon grid overlay.
/// </summary>
public partial class MapGraticule : MapGrid
{ {
/// <summary> protected override void DrawGrid(PathFigureCollection figures, List<Label> labels)
/// Draws a map graticule, i.e. a lat/lon grid overlay.
/// </summary>
public partial class MapGraticule : MapGrid
{ {
protected override void DrawGrid(PathFigureCollection figures, List<Label> labels) if (ParentMap.MapProjection.IsNormalCylindrical)
{ {
if (ParentMap.MapProjection.IsNormalCylindrical) DrawNormalGraticule(figures, labels);
}
else
{
DrawGraticule(figures, labels);
}
}
private static readonly double[] lineDistances = [
1d/3600d, 1d/1800d, 1d/720d, 1d/360d, 1d/240d, 1d/120d,
1d/60d, 1d/30d, 1d/12d, 1d/6d, 1d/4d, 1d/2d,
1d, 2d, 5d, 10d, 15d, 30d];
private static string GetLabelFormat(double lineDistance)
{
return lineDistance < 1d / 60d ? "{0} {1}°{2:00}'{3:00}\"" :
lineDistance < 1d ? "{0} {1}°{2:00}'" : "{0} {1}°";
}
private double GetLineDistance(bool scaleByLatitude)
{
var minDistance = MinLineDistance / (ParentMap.ViewTransform.Scale * MapProjection.Wgs84MeterPerDegree);
if (scaleByLatitude)
{
minDistance /= Math.Cos(ParentMap.Center.Latitude * Math.PI / 180d);
}
minDistance = Math.Max(minDistance, lineDistances.First());
minDistance = Math.Min(minDistance, lineDistances.Last());
return lineDistances.First(d => d >= minDistance);
}
private void DrawNormalGraticule(PathFigureCollection figures, List<Label> labels)
{
var lineDistance = GetLineDistance(false);
var labelFormat = GetLabelFormat(lineDistance);
var mapRect = ParentMap.ViewTransform.ViewToMapBounds(new Rect(0d, 0d, ParentMap.ActualWidth, ParentMap.ActualHeight));
var southWest = ParentMap.MapProjection.MapToLocation(mapRect.X, mapRect.Y);
var northEast = ParentMap.MapProjection.MapToLocation(mapRect.X + mapRect.Width, mapRect.Y + mapRect.Height);
var minLat = Math.Ceiling(southWest.Latitude / lineDistance) * lineDistance;
var minLon = Math.Ceiling(southWest.Longitude / lineDistance) * lineDistance;
for (var lat = minLat; lat <= northEast.Latitude; lat += lineDistance)
{
var p1 = ParentMap.LocationToView(lat, southWest.Longitude);
var p2 = ParentMap.LocationToView(lat, northEast.Longitude);
figures.Add(CreateLineFigure(p1, p2));
if (ParentMap.ViewTransform.Rotation == 0d)
{ {
DrawNormalGraticule(figures, labels); var text = GetLatitudeLabelText(lat, labelFormat);
labels.Add(new Label(text, p1.X, p1.Y, 0d, HorizontalAlignment.Left, VerticalAlignment.Bottom));
labels.Add(new Label(text, p2.X, p2.Y, 0d, HorizontalAlignment.Right, VerticalAlignment.Bottom));
}
}
for (var lon = minLon; lon <= northEast.Longitude; lon += lineDistance)
{
var p1 = ParentMap.LocationToView(southWest.Latitude, lon);
var p2 = ParentMap.LocationToView(northEast.Latitude, lon);
figures.Add(CreateLineFigure(p1, p2));
if (ParentMap.ViewTransform.Rotation == 0d)
{
var text = GetLongitudeLabelText(lon, labelFormat);
labels.Add(new Label(text, p1.X, p1.Y, 0d, HorizontalAlignment.Left, VerticalAlignment.Bottom));
labels.Add(new Label(text, p2.X, p2.Y, 0d, HorizontalAlignment.Left, VerticalAlignment.Top));
} }
else else
{ {
DrawGraticule(figures, labels); for (var lat = minLat; lat <= northEast.Latitude; lat += lineDistance)
{
AddLabel(labels, labelFormat, lat, lon, ParentMap.LocationToView(lat, lon), 0d);
}
} }
} }
}
private static readonly double[] lineDistances = [ private void DrawGraticule(PathFigureCollection figures, List<Label> labels)
1d/3600d, 1d/1800d, 1d/720d, 1d/360d, 1d/240d, 1d/120d, {
1d/60d, 1d/30d, 1d/12d, 1d/6d, 1d/4d, 1d/2d, var lineDistance = GetLineDistance(true);
1d, 2d, 5d, 10d, 15d, 30d]; var labelFormat = GetLabelFormat(lineDistance);
var pointDistance = Math.Min(lineDistance, 1d);
var interpolationCount = (int)(lineDistance / pointDistance);
var center = Math.Round(ParentMap.Center.Longitude / lineDistance) * lineDistance;
var minLat = Math.Round(ParentMap.Center.Latitude / pointDistance) * pointDistance;
var maxLat = minLat;
var minLon = center;
var maxLon = center;
private static string GetLabelFormat(double lineDistance) for (var lon = center;
lon >= center - 180d && DrawMeridian(figures, lon, pointDistance, ref minLat, ref maxLat);
lon -= lineDistance)
{ {
return lineDistance < 1d / 60d ? "{0} {1}°{2:00}'{3:00}\"" : minLon = lon;
lineDistance < 1d ? "{0} {1}°{2:00}'" : "{0} {1}°";
} }
private double GetLineDistance(bool scaleByLatitude) for (var lon = center + lineDistance;
lon < center + 180d && DrawMeridian(figures, lon, pointDistance, ref minLat, ref maxLat);
lon += lineDistance)
{ {
var minDistance = MinLineDistance / (ParentMap.ViewTransform.Scale * MapProjection.Wgs84MeterPerDegree); maxLon = lon;
if (scaleByLatitude)
{
minDistance /= Math.Cos(ParentMap.Center.Latitude * Math.PI / 180d);
}
minDistance = Math.Max(minDistance, lineDistances.First());
minDistance = Math.Min(minDistance, lineDistances.Last());
return lineDistances.First(d => d >= minDistance);
} }
private void DrawNormalGraticule(PathFigureCollection figures, List<Label> labels) if (minLon + 360d > maxLon)
{ {
var lineDistance = GetLineDistance(false); minLon -= lineDistance;
var labelFormat = GetLabelFormat(lineDistance);
var mapRect = ParentMap.ViewTransform.ViewToMapBounds(new Rect(0d, 0d, ParentMap.ActualWidth, ParentMap.ActualHeight));
var southWest = ParentMap.MapProjection.MapToLocation(mapRect.X, mapRect.Y);
var northEast = ParentMap.MapProjection.MapToLocation(mapRect.X + mapRect.Width, mapRect.Y + mapRect.Height);
var minLat = Math.Ceiling(southWest.Latitude / lineDistance) * lineDistance;
var minLon = Math.Ceiling(southWest.Longitude / lineDistance) * lineDistance;
for (var lat = minLat; lat <= northEast.Latitude; lat += lineDistance)
{
var p1 = ParentMap.LocationToView(lat, southWest.Longitude);
var p2 = ParentMap.LocationToView(lat, northEast.Longitude);
figures.Add(CreateLineFigure(p1, p2));
if (ParentMap.ViewTransform.Rotation == 0d)
{
var text = GetLatitudeLabelText(lat, labelFormat);
labels.Add(new Label(text, p1.X, p1.Y, 0d, HorizontalAlignment.Left, VerticalAlignment.Bottom));
labels.Add(new Label(text, p2.X, p2.Y, 0d, HorizontalAlignment.Right, VerticalAlignment.Bottom));
}
}
for (var lon = minLon; lon <= northEast.Longitude; lon += lineDistance)
{
var p1 = ParentMap.LocationToView(southWest.Latitude, lon);
var p2 = ParentMap.LocationToView(northEast.Latitude, lon);
figures.Add(CreateLineFigure(p1, p2));
if (ParentMap.ViewTransform.Rotation == 0d)
{
var text = GetLongitudeLabelText(lon, labelFormat);
labels.Add(new Label(text, p1.X, p1.Y, 0d, HorizontalAlignment.Left, VerticalAlignment.Bottom));
labels.Add(new Label(text, p2.X, p2.Y, 0d, HorizontalAlignment.Left, VerticalAlignment.Top));
}
else
{
for (var lat = minLat; lat <= northEast.Latitude; lat += lineDistance)
{
AddLabel(labels, labelFormat, lat, lon, ParentMap.LocationToView(lat, lon), 0d);
}
}
}
} }
private void DrawGraticule(PathFigureCollection figures, List<Label> labels) if (maxLon - 360d < minLon)
{ {
var lineDistance = GetLineDistance(true); maxLon += lineDistance;
var labelFormat = GetLabelFormat(lineDistance);
var pointDistance = Math.Min(lineDistance, 1d);
var interpolationCount = (int)(lineDistance / pointDistance);
var center = Math.Round(ParentMap.Center.Longitude / lineDistance) * lineDistance;
var minLat = Math.Round(ParentMap.Center.Latitude / pointDistance) * pointDistance;
var maxLat = minLat;
var minLon = center;
var maxLon = center;
for (var lon = center;
lon >= center - 180d && DrawMeridian(figures, lon, pointDistance, ref minLat, ref maxLat);
lon -= lineDistance)
{
minLon = lon;
}
for (var lon = center + lineDistance;
lon < center + 180d && DrawMeridian(figures, lon, pointDistance, ref minLat, ref maxLat);
lon += lineDistance)
{
maxLon = lon;
}
if (minLon + 360d > maxLon)
{
minLon -= lineDistance;
}
if (maxLon - 360d < minLon)
{
maxLon += lineDistance;
}
if (pointDistance < lineDistance)
{
minLat = Math.Ceiling(minLat / lineDistance - 1e-6) * lineDistance;
maxLat = Math.Floor(maxLat / lineDistance + 1e-6) * lineDistance;
}
maxLat += 1e-6;
maxLon += 1e-6;
for (var lat = minLat; lat <= maxLat; lat += lineDistance)
{
var points = new List<Point>();
for (var lon = minLon; lon <= maxLon; lon += lineDistance)
{
var p = ParentMap.LocationToView(lat, lon);
points.Add(p);
AddLabel(labels, labelFormat, lat, lon, p, -ParentMap.MapProjection.GridConvergence(lat, lon));
for (int j = 1; j < interpolationCount; j++)
{
points.Add(ParentMap.LocationToView(lat, lon + j * pointDistance));
}
}
if (points.Count >= 2)
{
figures.Add(CreatePolylineFigure(points));
}
}
} }
private bool DrawMeridian(PathFigureCollection figures, double lon, if (pointDistance < lineDistance)
double latStep, ref double minLat, ref double maxLat) {
minLat = Math.Ceiling(minLat / lineDistance - 1e-6) * lineDistance;
maxLat = Math.Floor(maxLat / lineDistance + 1e-6) * lineDistance;
}
maxLat += 1e-6;
maxLon += 1e-6;
for (var lat = minLat; lat <= maxLat; lat += lineDistance)
{ {
var points = new List<Point>(); var points = new List<Point>();
var visible = false;
for (var lat = minLat + latStep; lat < maxLat; lat += latStep) for (var lon = minLon; lon <= maxLon; lon += lineDistance)
{ {
var p = ParentMap.LocationToView(lat, lon); var p = ParentMap.LocationToView(lat, lon);
points.Add(p); points.Add(p);
visible = visible || ParentMap.InsideViewBounds(p); AddLabel(labels, labelFormat, lat, lon, p, -ParentMap.MapProjection.GridConvergence(lat, lon));
for (int j = 1; j < interpolationCount; j++)
{
points.Add(ParentMap.LocationToView(lat, lon + j * pointDistance));
}
} }
for (var lat = minLat; lat >= -90d; lat -= latStep) if (points.Count >= 2)
{
var p = ParentMap.LocationToView(lat, lon);
points.Insert(0, p);
if (!ParentMap.InsideViewBounds(p)) break;
minLat = lat;
visible = true;
}
for (var lat = maxLat; lat <= 90d; lat += latStep)
{
var p = ParentMap.LocationToView(lat, lon);
points.Add(p);
if (!ParentMap.InsideViewBounds(p)) break;
maxLat = lat;
visible = true;
}
if (visible && points.Count >= 2)
{ {
figures.Add(CreatePolylineFigure(points)); figures.Add(CreatePolylineFigure(points));
} }
return visible;
}
private void AddLabel(List<Label> labels, string labelFormat, double lat, double lon, Point position, double rotation)
{
if (lat > -90d && lat < 90d && ParentMap.InsideViewBounds(position))
{
rotation = ((rotation + ParentMap.ViewTransform.Rotation) % 360d + 540d) % 360d - 180d;
if (rotation < -90d)
{
rotation += 180d;
}
else if (rotation > 90d)
{
rotation -= 180d;
}
var text = GetLatitudeLabelText(lat, labelFormat) +
"\n" + GetLongitudeLabelText(lon, labelFormat);
labels.Add(new Label(text, position.X, position.Y, rotation));
}
}
private static string GetLatitudeLabelText(double value, string labelFormat)
{
return GetLabelText(value, labelFormat, "NS");
}
private static string GetLongitudeLabelText(double value, string labelFormat)
{
return GetLabelText(Location.NormalizeLongitude(value), labelFormat, "EW");
}
private static string GetLabelText(double value, string labelFormat, string hemispheres)
{
var hemisphere = hemispheres[0];
if (value < -1e-8) // ~1 mm
{
value = -value;
hemisphere = hemispheres[1];
}
var seconds = (int)Math.Round(value * 3600d);
return string.Format(CultureInfo.InvariantCulture,
labelFormat, hemisphere, seconds / 3600, seconds / 60 % 60, seconds % 60);
} }
} }
private bool DrawMeridian(PathFigureCollection figures, double lon,
double latStep, ref double minLat, ref double maxLat)
{
var points = new List<Point>();
var visible = false;
for (var lat = minLat + latStep; lat < maxLat; lat += latStep)
{
var p = ParentMap.LocationToView(lat, lon);
points.Add(p);
visible = visible || ParentMap.InsideViewBounds(p);
}
for (var lat = minLat; lat >= -90d; lat -= latStep)
{
var p = ParentMap.LocationToView(lat, lon);
points.Insert(0, p);
if (!ParentMap.InsideViewBounds(p)) break;
minLat = lat;
visible = true;
}
for (var lat = maxLat; lat <= 90d; lat += latStep)
{
var p = ParentMap.LocationToView(lat, lon);
points.Add(p);
if (!ParentMap.InsideViewBounds(p)) break;
maxLat = lat;
visible = true;
}
if (visible && points.Count >= 2)
{
figures.Add(CreatePolylineFigure(points));
}
return visible;
}
private void AddLabel(List<Label> labels, string labelFormat, double lat, double lon, Point position, double rotation)
{
if (lat > -90d && lat < 90d && ParentMap.InsideViewBounds(position))
{
rotation = ((rotation + ParentMap.ViewTransform.Rotation) % 360d + 540d) % 360d - 180d;
if (rotation < -90d)
{
rotation += 180d;
}
else if (rotation > 90d)
{
rotation -= 180d;
}
var text = GetLatitudeLabelText(lat, labelFormat) +
"\n" + GetLongitudeLabelText(lon, labelFormat);
labels.Add(new Label(text, position.X, position.Y, rotation));
}
}
private static string GetLatitudeLabelText(double value, string labelFormat)
{
return GetLabelText(value, labelFormat, "NS");
}
private static string GetLongitudeLabelText(double value, string labelFormat)
{
return GetLabelText(Location.NormalizeLongitude(value), labelFormat, "EW");
}
private static string GetLabelText(double value, string labelFormat, string hemispheres)
{
var hemisphere = hemispheres[0];
if (value < -1e-8) // ~1 mm
{
value = -value;
hemisphere = hemispheres[1];
}
var seconds = (int)Math.Round(value * 3600d);
return string.Format(CultureInfo.InvariantCulture,
labelFormat, hemisphere, seconds / 3600, seconds / 60 % 60, seconds % 60);
}
} }

View file

@ -17,90 +17,89 @@ using Brush = Avalonia.Media.IBrush;
using PathFigureCollection = Avalonia.Media.PathFigures; using PathFigureCollection = Avalonia.Media.PathFigures;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Base class of map grid or graticule overlays.
/// </summary>
public abstract partial class MapGrid
{ {
/// <summary> protected class Label(string text, double x, double y, double rotation,
/// Base class of map grid or graticule overlays. HorizontalAlignment horizontalAlignment = HorizontalAlignment.Left,
/// </summary> VerticalAlignment verticalAlignment = VerticalAlignment.Center)
public abstract partial class MapGrid
{ {
protected class Label(string text, double x, double y, double rotation, public string Text => text;
HorizontalAlignment horizontalAlignment = HorizontalAlignment.Left, public double X => x;
VerticalAlignment verticalAlignment = VerticalAlignment.Center) public double Y => y;
public double Rotation => rotation;
public HorizontalAlignment HorizontalAlignment => horizontalAlignment;
public VerticalAlignment VerticalAlignment => verticalAlignment;
}
public static readonly DependencyProperty MinLineDistanceProperty =
DependencyPropertyHelper.Register<MapGrid, double>(nameof(MinLineDistance), 150d);
public static readonly DependencyProperty StrokeThicknessProperty =
DependencyPropertyHelper.Register<MapGrid, double>(nameof(StrokeThickness), 0.5);
/// <summary>
/// Minimum graticule line distance in pixels. The default value is 150.
/// </summary>
public double MinLineDistance
{
get => (double)GetValue(MinLineDistanceProperty);
set => SetValue(MinLineDistanceProperty, value);
}
public double StrokeThickness
{
get => (double)GetValue(StrokeThicknessProperty);
set => SetValue(StrokeThicknessProperty, value);
}
public Brush Foreground
{
get => (Brush)GetValue(ForegroundProperty);
set => SetValue(ForegroundProperty, value);
}
public FontFamily FontFamily
{
get => (FontFamily)GetValue(FontFamilyProperty);
set => SetValue(FontFamilyProperty, value);
}
public double FontSize
{
get => (double)GetValue(FontSizeProperty);
set => SetValue(FontSizeProperty, value);
}
protected abstract void DrawGrid(PathFigureCollection figures, List<Label> labels);
protected static PathFigure CreateLineFigure(Point p1, Point p2)
{
var figure = new PathFigure
{ {
public string Text => text; StartPoint = p1,
public double X => x; IsClosed = false,
public double Y => y; IsFilled = false
public double Rotation => rotation; };
public HorizontalAlignment HorizontalAlignment => horizontalAlignment;
public VerticalAlignment VerticalAlignment => verticalAlignment;
}
public static readonly DependencyProperty MinLineDistanceProperty = figure.Segments.Add(new LineSegment { Point = p2 });
DependencyPropertyHelper.Register<MapGrid, double>(nameof(MinLineDistance), 150d); return figure;
}
public static readonly DependencyProperty StrokeThicknessProperty = protected static PathFigure CreatePolylineFigure(IEnumerable<Point> points)
DependencyPropertyHelper.Register<MapGrid, double>(nameof(StrokeThickness), 0.5); {
var figure = new PathFigure
/// <summary>
/// Minimum graticule line distance in pixels. The default value is 150.
/// </summary>
public double MinLineDistance
{ {
get => (double)GetValue(MinLineDistanceProperty); StartPoint = points.First(),
set => SetValue(MinLineDistanceProperty, value); IsClosed = false,
} IsFilled = false
};
public double StrokeThickness figure.Segments.Add(CreatePolyLineSegment(points.Skip(1)));
{ return figure;
get => (double)GetValue(StrokeThicknessProperty);
set => SetValue(StrokeThicknessProperty, value);
}
public Brush Foreground
{
get => (Brush)GetValue(ForegroundProperty);
set => SetValue(ForegroundProperty, value);
}
public FontFamily FontFamily
{
get => (FontFamily)GetValue(FontFamilyProperty);
set => SetValue(FontFamilyProperty, value);
}
public double FontSize
{
get => (double)GetValue(FontSizeProperty);
set => SetValue(FontSizeProperty, value);
}
protected abstract void DrawGrid(PathFigureCollection figures, List<Label> labels);
protected static PathFigure CreateLineFigure(Point p1, Point p2)
{
var figure = new PathFigure
{
StartPoint = p1,
IsClosed = false,
IsFilled = false
};
figure.Segments.Add(new LineSegment { Point = p2 });
return figure;
}
protected static PathFigure CreatePolylineFigure(IEnumerable<Point> points)
{
var figure = new PathFigure
{
StartPoint = points.First(),
IsClosed = false,
IsFilled = false
};
figure.Segments.Add(CreatePolyLineSegment(points.Skip(1)));
return figure;
}
} }
} }

View file

@ -21,215 +21,214 @@ using Brush = Avalonia.Media.IBrush;
using ImageSource = Avalonia.Media.IImage; using ImageSource = Avalonia.Media.IImage;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Displays a single map image, e.g. from a Web Map Service (WMS).
/// The image must be provided by the abstract GetImageAsync() method.
/// </summary>
public abstract partial class MapImageLayer : MapPanel, IMapLayer
{ {
/// <summary> public static readonly DependencyProperty DescriptionProperty =
/// Displays a single map image, e.g. from a Web Map Service (WMS). DependencyPropertyHelper.Register<MapImageLayer, string>(nameof(Description));
/// The image must be provided by the abstract GetImageAsync() method.
/// </summary> public static readonly DependencyProperty RelativeImageSizeProperty =
public abstract partial class MapImageLayer : MapPanel, IMapLayer DependencyPropertyHelper.Register<MapImageLayer, double>(nameof(RelativeImageSize), 1d);
public static readonly DependencyProperty UpdateIntervalProperty =
DependencyPropertyHelper.Register<MapImageLayer, TimeSpan>(nameof(UpdateInterval), TimeSpan.FromSeconds(0.2),
(layer, oldValue, newValue) => layer.updateTimer.Interval = newValue);
public static readonly DependencyProperty UpdateWhileViewportChangingProperty =
DependencyPropertyHelper.Register<MapImageLayer, bool>(nameof(UpdateWhileViewportChanging));
public static readonly DependencyProperty MapBackgroundProperty =
DependencyPropertyHelper.Register<MapImageLayer, Brush>(nameof(MapBackground));
public static readonly DependencyProperty MapForegroundProperty =
DependencyPropertyHelper.Register<MapImageLayer, Brush>(nameof(MapForeground));
public static readonly DependencyProperty LoadingProgressProperty =
DependencyPropertyHelper.Register<MapImageLayer, double>(nameof(LoadingProgress), 1d);
private readonly Progress<double> loadingProgress;
private readonly UpdateTimer updateTimer;
private bool updateInProgress;
public MapImageLayer()
{ {
public static readonly DependencyProperty DescriptionProperty = IsHitTestVisible = false;
DependencyPropertyHelper.Register<MapImageLayer, string>(nameof(Description));
public static readonly DependencyProperty RelativeImageSizeProperty = loadingProgress = new Progress<double>(p => SetValue(LoadingProgressProperty, p));
DependencyPropertyHelper.Register<MapImageLayer, double>(nameof(RelativeImageSize), 1d);
public static readonly DependencyProperty UpdateIntervalProperty = updateTimer = new UpdateTimer { Interval = UpdateInterval };
DependencyPropertyHelper.Register<MapImageLayer, TimeSpan>(nameof(UpdateInterval), TimeSpan.FromSeconds(0.2), updateTimer.Tick += async (_, _) => await UpdateImageAsync();
(layer, oldValue, newValue) => layer.updateTimer.Interval = newValue); }
public static readonly DependencyProperty UpdateWhileViewportChangingProperty = /// <summary>
DependencyPropertyHelper.Register<MapImageLayer, bool>(nameof(UpdateWhileViewportChanging)); /// Description of the layer. Used to display copyright information on top of the map.
/// </summary>
public string Description
{
get => (string)GetValue(DescriptionProperty);
set => SetValue(DescriptionProperty, value);
}
public static readonly DependencyProperty MapBackgroundProperty = /// <summary>
DependencyPropertyHelper.Register<MapImageLayer, Brush>(nameof(MapBackground)); /// Relative size of the map image in relation to the current view size.
/// Setting a value greater than one will let MapImageLayer request images that
/// are larger than the view, in order to support smooth panning.
/// </summary>
public double RelativeImageSize
{
get => (double)GetValue(RelativeImageSizeProperty);
set => SetValue(RelativeImageSizeProperty, value);
}
public static readonly DependencyProperty MapForegroundProperty = /// <summary>
DependencyPropertyHelper.Register<MapImageLayer, Brush>(nameof(MapForeground)); /// Minimum time interval between images updates.
/// </summary>
public TimeSpan UpdateInterval
{
get => (TimeSpan)GetValue(UpdateIntervalProperty);
set => SetValue(UpdateIntervalProperty, value);
}
public static readonly DependencyProperty LoadingProgressProperty = /// <summary>
DependencyPropertyHelper.Register<MapImageLayer, double>(nameof(LoadingProgress), 1d); /// Controls if images are updated while the viewport is still changing.
/// </summary>
public bool UpdateWhileViewportChanging
{
get => (bool)GetValue(UpdateWhileViewportChangingProperty);
set => SetValue(UpdateWhileViewportChangingProperty, value);
}
private readonly Progress<double> loadingProgress; /// <summary>
private readonly UpdateTimer updateTimer; /// Optional background brush. Sets MapBase.Background if not null and this layer is the base map layer.
private bool updateInProgress; /// </summary>
public Brush MapBackground
{
get => (Brush)GetValue(MapBackgroundProperty);
set => SetValue(MapBackgroundProperty, value);
}
public MapImageLayer() /// <summary>
/// Optional foreground brush. Sets MapBase.Foreground if not null and this layer is the base map layer.
/// </summary>
public Brush MapForeground
{
get => (Brush)GetValue(MapForegroundProperty);
set => SetValue(MapForegroundProperty, value);
}
/// <summary>
/// Gets the progress of the ImageLoader as a double value between 0 and 1.
/// </summary>
public double LoadingProgress => (double)GetValue(LoadingProgressProperty);
protected override void SetParentMap(MapBase map)
{
if (map != null)
{ {
IsHitTestVisible = false; while (Children.Count < 2)
loadingProgress = new Progress<double>(p => SetValue(LoadingProgressProperty, p));
updateTimer = new UpdateTimer { Interval = UpdateInterval };
updateTimer.Tick += async (_, _) => await UpdateImageAsync();
}
/// <summary>
/// Description of the layer. Used to display copyright information on top of the map.
/// </summary>
public string Description
{
get => (string)GetValue(DescriptionProperty);
set => SetValue(DescriptionProperty, value);
}
/// <summary>
/// Relative size of the map image in relation to the current view size.
/// Setting a value greater than one will let MapImageLayer request images that
/// are larger than the view, in order to support smooth panning.
/// </summary>
public double RelativeImageSize
{
get => (double)GetValue(RelativeImageSizeProperty);
set => SetValue(RelativeImageSizeProperty, value);
}
/// <summary>
/// Minimum time interval between images updates.
/// </summary>
public TimeSpan UpdateInterval
{
get => (TimeSpan)GetValue(UpdateIntervalProperty);
set => SetValue(UpdateIntervalProperty, value);
}
/// <summary>
/// Controls if images are updated while the viewport is still changing.
/// </summary>
public bool UpdateWhileViewportChanging
{
get => (bool)GetValue(UpdateWhileViewportChangingProperty);
set => SetValue(UpdateWhileViewportChangingProperty, value);
}
/// <summary>
/// Optional background brush. Sets MapBase.Background if not null and this layer is the base map layer.
/// </summary>
public Brush MapBackground
{
get => (Brush)GetValue(MapBackgroundProperty);
set => SetValue(MapBackgroundProperty, value);
}
/// <summary>
/// Optional foreground brush. Sets MapBase.Foreground if not null and this layer is the base map layer.
/// </summary>
public Brush MapForeground
{
get => (Brush)GetValue(MapForegroundProperty);
set => SetValue(MapForegroundProperty, value);
}
/// <summary>
/// Gets the progress of the ImageLoader as a double value between 0 and 1.
/// </summary>
public double LoadingProgress => (double)GetValue(LoadingProgressProperty);
protected override void SetParentMap(MapBase map)
{
if (map != null)
{ {
while (Children.Count < 2) Children.Add(new Image
{ {
Children.Add(new Image Opacity = 0d,
{ Stretch = Stretch.Fill
Opacity = 0d, });
Stretch = Stretch.Fill }
}); }
else
{
updateTimer.Stop();
ClearImages();
Children.Clear();
}
base.SetParentMap(map);
}
protected override async void OnViewportChanged(ViewportChangedEventArgs e)
{
base.OnViewportChanged(e);
if (e.ProjectionChanged)
{
ClearImages();
await UpdateImageAsync(); // update immediately
}
else
{
updateTimer.Run(!UpdateWhileViewportChanging);
}
}
protected abstract Task<ImageSource> GetImageAsync(Rect mapRect, IProgress<double> progress);
protected async Task UpdateImageAsync()
{
if (!updateInProgress)
{
updateInProgress = true;
updateTimer.Stop();
ImageSource image = null;
Rect? mapRect = null;
if (ParentMap != null)
{
var width = ParentMap.ActualWidth * RelativeImageSize;
var height = ParentMap.ActualHeight * RelativeImageSize;
if (width > 0d && height > 0d)
{
var x = (ParentMap.ActualWidth - width) / 2d;
var y = (ParentMap.ActualHeight - height) / 2d;
mapRect = ParentMap.ViewTransform.ViewToMapBounds(new Rect(x, y, width, height));
image = await GetImageAsync(mapRect.Value, loadingProgress);
} }
} }
SwapImages(image, mapRect);
updateInProgress = false;
}
else // update on next timer tick
{
updateTimer.Run();
}
}
private void ClearImages()
{
foreach (var image in Children.OfType<Image>())
{
image.ClearValue(MapRectProperty);
image.ClearValue(Image.SourceProperty);
}
}
private void SwapImages(ImageSource image, Rect? mapRect)
{
if (Children.Count >= 2)
{
var topImage = (Image)Children[0];
Children.RemoveAt(0);
Children.Insert(1, topImage);
topImage.Source = image;
SetMapRect(topImage, mapRect);
if (MapBase.ImageFadeDuration > TimeSpan.Zero)
{
FadeOver();
}
else else
{ {
updateTimer.Stop(); topImage.Opacity = 1d;
ClearImages(); Children[0].Opacity = 0d;
Children.Clear();
}
base.SetParentMap(map);
}
protected override async void OnViewportChanged(ViewportChangedEventArgs e)
{
base.OnViewportChanged(e);
if (e.ProjectionChanged)
{
ClearImages();
await UpdateImageAsync(); // update immediately
}
else
{
updateTimer.Run(!UpdateWhileViewportChanging);
}
}
protected abstract Task<ImageSource> GetImageAsync(Rect mapRect, IProgress<double> progress);
protected async Task UpdateImageAsync()
{
if (!updateInProgress)
{
updateInProgress = true;
updateTimer.Stop();
ImageSource image = null;
Rect? mapRect = null;
if (ParentMap != null)
{
var width = ParentMap.ActualWidth * RelativeImageSize;
var height = ParentMap.ActualHeight * RelativeImageSize;
if (width > 0d && height > 0d)
{
var x = (ParentMap.ActualWidth - width) / 2d;
var y = (ParentMap.ActualHeight - height) / 2d;
mapRect = ParentMap.ViewTransform.ViewToMapBounds(new Rect(x, y, width, height));
image = await GetImageAsync(mapRect.Value, loadingProgress);
}
}
SwapImages(image, mapRect);
updateInProgress = false;
}
else // update on next timer tick
{
updateTimer.Run();
}
}
private void ClearImages()
{
foreach (var image in Children.OfType<Image>())
{
image.ClearValue(MapRectProperty);
image.ClearValue(Image.SourceProperty);
}
}
private void SwapImages(ImageSource image, Rect? mapRect)
{
if (Children.Count >= 2)
{
var topImage = (Image)Children[0];
Children.RemoveAt(0);
Children.Insert(1, topImage);
topImage.Source = image;
SetMapRect(topImage, mapRect);
if (MapBase.ImageFadeDuration > TimeSpan.Zero)
{
FadeOver();
}
else
{
topImage.Opacity = 1d;
Children[0].Opacity = 0d;
}
} }
} }
} }

View file

@ -15,101 +15,100 @@ using Avalonia.Controls;
using Avalonia.Media; using Avalonia.Media;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Container class for an item in a MapItemsControl.
/// </summary>
public partial class MapItem : ListBoxItem, IMapElement
{ {
public static readonly DependencyProperty LocationProperty =
DependencyPropertyHelper.AddOwner<MapItem, Location>(
nameof(Location), MapPanel.LocationProperty, (item, _, _) => item.UpdateMapTransform());
public static readonly DependencyProperty AutoCollapseProperty =
DependencyPropertyHelper.AddOwner<MapItem, bool>(
nameof(AutoCollapse), MapPanel.AutoCollapseProperty);
/// <summary> /// <summary>
/// Container class for an item in a MapItemsControl. /// Gets/sets MapPanel.Location.
/// </summary> /// </summary>
public partial class MapItem : ListBoxItem, IMapElement public Location Location
{ {
public static readonly DependencyProperty LocationProperty = get => (Location)GetValue(LocationProperty);
DependencyPropertyHelper.AddOwner<MapItem, Location>( set => SetValue(LocationProperty, value);
nameof(Location), MapPanel.LocationProperty, (item, _, _) => item.UpdateMapTransform()); }
public static readonly DependencyProperty AutoCollapseProperty = /// <summary>
DependencyPropertyHelper.AddOwner<MapItem, bool>( /// Gets/sets MapPanel.AutoCollapse.
nameof(AutoCollapse), MapPanel.AutoCollapseProperty); /// </summary>
public bool AutoCollapse
{
get => (bool)GetValue(AutoCollapseProperty);
set => SetValue(AutoCollapseProperty, value);
}
/// <summary> /// <summary>
/// Gets/sets MapPanel.Location. /// Implements IMapElement.ParentMap.
/// </summary> /// </summary>
public Location Location public MapBase ParentMap
{
get;
set
{ {
get => (Location)GetValue(LocationProperty); if (field != null)
set => SetValue(LocationProperty, value);
}
/// <summary>
/// Gets/sets MapPanel.AutoCollapse.
/// </summary>
public bool AutoCollapse
{
get => (bool)GetValue(AutoCollapseProperty);
set => SetValue(AutoCollapseProperty, value);
}
/// <summary>
/// Implements IMapElement.ParentMap.
/// </summary>
public MapBase ParentMap
{
get;
set
{ {
if (field != null) field.ViewportChanged -= OnViewportChanged;
{ }
field.ViewportChanged -= OnViewportChanged;
}
field = value; field = value;
if (field != null && mapTransform != null) if (field != null && mapTransform != null)
{
// Attach ViewportChanged handler only if MapTransform is actually used.
//
field.ViewportChanged += OnViewportChanged;
UpdateMapTransform();
}
}
}
/// <summary>
/// Gets a Transform for scaling and rotating geometries
/// in map coordinates (meters) to view coordinates (pixels).
/// </summary>
public MatrixTransform MapTransform
{
get
{
if (mapTransform == null)
{
mapTransform = new MatrixTransform();
if (ParentMap != null)
{ {
// Attach ViewportChanged handler only if MapTransform is actually used. ParentMap.ViewportChanged += OnViewportChanged;
//
field.ViewportChanged += OnViewportChanged;
UpdateMapTransform(); UpdateMapTransform();
} }
} }
}
/// <summary> return mapTransform;
/// Gets a Transform for scaling and rotating geometries
/// in map coordinates (meters) to view coordinates (pixels).
/// </summary>
public MatrixTransform MapTransform
{
get
{
if (mapTransform == null)
{
mapTransform = new MatrixTransform();
if (ParentMap != null)
{
ParentMap.ViewportChanged += OnViewportChanged;
UpdateMapTransform();
}
}
return mapTransform;
}
}
private MatrixTransform mapTransform;
private void UpdateMapTransform()
{
if (mapTransform != null && ParentMap != null && Location != null)
{
mapTransform.Matrix = ParentMap.GetMapToViewTransform(Location);
}
}
private void OnViewportChanged(object sender, ViewportChangedEventArgs e)
{
UpdateMapTransform();
} }
} }
private MatrixTransform mapTransform;
private void UpdateMapTransform()
{
if (mapTransform != null && ParentMap != null && Location != null)
{
mapTransform.Matrix = ParentMap.GetMapToViewTransform(Location);
}
}
private void OnViewportChanged(object sender, ViewportChangedEventArgs e)
{
UpdateMapTransform();
}
} }

View file

@ -18,126 +18,125 @@ using Avalonia.Data;
using PropertyPath = System.String; using PropertyPath = System.String;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// An ItemsControl with selectable items on a Map. Uses MapItem as item container.
/// </summary>
public partial class MapItemsControl : ListBox
{ {
public static readonly DependencyProperty LocationMemberPathProperty =
DependencyPropertyHelper.Register<MapItemsControl, string>(nameof(LocationMemberPath));
/// <summary> /// <summary>
/// An ItemsControl with selectable items on a Map. Uses MapItem as item container. /// Path to a source property for binding the Location property of MapItem containers.
/// </summary> /// </summary>
public partial class MapItemsControl : ListBox public string LocationMemberPath
{ {
public static readonly DependencyProperty LocationMemberPathProperty = get => (string)GetValue(LocationMemberPathProperty);
DependencyPropertyHelper.Register<MapItemsControl, string>(nameof(LocationMemberPath)); set => SetValue(LocationMemberPathProperty, value);
}
/// <summary> public void SelectItems(Predicate<object> predicate)
/// Path to a source property for binding the Location property of MapItem containers. {
/// </summary> if (SelectionMode == SelectionMode.Single)
public string LocationMemberPath
{ {
get => (string)GetValue(LocationMemberPathProperty); throw new InvalidOperationException("SelectionMode must not be Single");
set => SetValue(LocationMemberPathProperty, value);
} }
public void SelectItems(Predicate<object> predicate) foreach (var item in Items)
{ {
if (SelectionMode == SelectionMode.Single) var selected = predicate(item);
{
throw new InvalidOperationException("SelectionMode must not be Single");
}
foreach (var item in Items) if (selected != SelectedItems.Contains(item))
{ {
var selected = predicate(item); if (selected)
if (selected != SelectedItems.Contains(item))
{ {
if (selected) SelectedItems.Add(item);
{
SelectedItems.Add(item);
}
else
{
SelectedItems.Remove(item);
}
} }
} else
}
public void SelectItemsByLocation(Predicate<Location> predicate)
{
SelectItems(item =>
{
var location = MapPanel.GetLocation(ContainerFromItem(item));
return location!= null && predicate(location);
});
}
public void SelectItemsByPosition(Predicate<Point> predicate)
{
SelectItems(item =>
{
var position = MapPanel.GetViewPosition(ContainerFromItem(item));
return position.HasValue && predicate(position.Value);
});
}
public void SelectItemsInRect(Rect rect)
{
SelectItemsByPosition(rect.Contains);
}
/// <summary>
/// Selects all items in a rectangular range between SelectedItem and the specified MapItem.
/// </summary>
internal void SelectItemsInRange(MapItem mapItem)
{
var position = MapPanel.GetViewPosition(mapItem);
if (position.HasValue)
{
var xMin = position.Value.X;
var xMax = position.Value.X;
var yMin = position.Value.Y;
var yMax = position.Value.Y;
if (SelectedItem != null)
{ {
var selectedMapItem = ContainerFromItem(SelectedItem); SelectedItems.Remove(item);
if (selectedMapItem != mapItem)
{
position = MapPanel.GetViewPosition(selectedMapItem);
if (position.HasValue)
{
xMin = Math.Min(xMin, position.Value.X);
xMax = Math.Max(xMax, position.Value.X);
yMin = Math.Min(yMin, position.Value.Y);
yMax = Math.Max(yMax, position.Value.Y);
}
}
} }
SelectItemsInRect(new Rect(xMin, yMin, xMax - xMin, yMax - yMin));
}
}
private void PrepareContainer(MapItem mapItem, object item)
{
if (LocationMemberPath != null)
{
mapItem.SetBinding(MapItem.LocationProperty,
new Binding { Source = item, Path = new PropertyPath(LocationMemberPath) });
}
}
private void ClearContainer(MapItem mapItem)
{
if (LocationMemberPath != null)
{
mapItem.ClearValue(MapItem.LocationProperty);
} }
} }
} }
public void SelectItemsByLocation(Predicate<Location> predicate)
{
SelectItems(item =>
{
var location = MapPanel.GetLocation(ContainerFromItem(item));
return location!= null && predicate(location);
});
}
public void SelectItemsByPosition(Predicate<Point> predicate)
{
SelectItems(item =>
{
var position = MapPanel.GetViewPosition(ContainerFromItem(item));
return position.HasValue && predicate(position.Value);
});
}
public void SelectItemsInRect(Rect rect)
{
SelectItemsByPosition(rect.Contains);
}
/// <summary>
/// Selects all items in a rectangular range between SelectedItem and the specified MapItem.
/// </summary>
internal void SelectItemsInRange(MapItem mapItem)
{
var position = MapPanel.GetViewPosition(mapItem);
if (position.HasValue)
{
var xMin = position.Value.X;
var xMax = position.Value.X;
var yMin = position.Value.Y;
var yMax = position.Value.Y;
if (SelectedItem != null)
{
var selectedMapItem = ContainerFromItem(SelectedItem);
if (selectedMapItem != mapItem)
{
position = MapPanel.GetViewPosition(selectedMapItem);
if (position.HasValue)
{
xMin = Math.Min(xMin, position.Value.X);
xMax = Math.Max(xMax, position.Value.X);
yMin = Math.Min(yMin, position.Value.Y);
yMax = Math.Max(yMax, position.Value.Y);
}
}
}
SelectItemsInRect(new Rect(xMin, yMin, xMax - xMin, yMax - yMin));
}
}
private void PrepareContainer(MapItem mapItem, object item)
{
if (LocationMemberPath != null)
{
mapItem.SetBinding(MapItem.LocationProperty,
new Binding { Source = item, Path = new PropertyPath(LocationMemberPath) });
}
}
private void ClearContainer(MapItem mapItem)
{
if (LocationMemberPath != null)
{
mapItem.ClearValue(MapItem.LocationProperty);
}
}
} }

View file

@ -7,34 +7,33 @@ using Windows.UI.Xaml;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// A multi-polygon defined by a collection of collections of Locations.
/// Allows to draw filled polygons with holes.
///
/// A PolygonCollection (with ObservableCollection of Location elements) may be used
/// for the Polygons property if collection changes of the property itself and its
/// elements are both supposed to trigger UI updates.
/// </summary>
public partial class MapMultiPolygon : MapPolypoint
{ {
public static readonly DependencyProperty PolygonsProperty =
DependencyPropertyHelper.Register<MapMultiPolygon, IEnumerable<IEnumerable<Location>>>(nameof(Polygons), null,
(polygon, oldValue, newValue) => polygon.DataCollectionPropertyChanged(oldValue, newValue));
/// <summary> /// <summary>
/// A multi-polygon defined by a collection of collections of Locations. /// Gets or sets the Locations that define the multi-polygon points.
/// Allows to draw filled polygons with holes.
///
/// A PolygonCollection (with ObservableCollection of Location elements) may be used
/// for the Polygons property if collection changes of the property itself and its
/// elements are both supposed to trigger UI updates.
/// </summary> /// </summary>
public partial class MapMultiPolygon : MapPolypoint public IEnumerable<IEnumerable<Location>> Polygons
{ {
public static readonly DependencyProperty PolygonsProperty = get => (IEnumerable<IEnumerable<Location>>)GetValue(PolygonsProperty);
DependencyPropertyHelper.Register<MapMultiPolygon, IEnumerable<IEnumerable<Location>>>(nameof(Polygons), null, set => SetValue(PolygonsProperty, value);
(polygon, oldValue, newValue) => polygon.DataCollectionPropertyChanged(oldValue, newValue)); }
/// <summary> protected override void UpdateData()
/// Gets or sets the Locations that define the multi-polygon points. {
/// </summary> UpdateData(Polygons);
public IEnumerable<IEnumerable<Location>> Polygons
{
get => (IEnumerable<IEnumerable<Location>>)GetValue(PolygonsProperty);
set => SetValue(PolygonsProperty, value);
}
protected override void UpdateData()
{
UpdateData(Polygons);
}
} }
} }

View file

@ -11,110 +11,109 @@ using Windows.UI.Xaml;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// A MapPanel with a collection of GroundOverlay or GeoImage children.
/// </summary>
public partial class MapOverlaysPanel : MapPanel
{ {
/// <summary> public static readonly DependencyProperty SourcePathsProperty =
/// A MapPanel with a collection of GroundOverlay or GeoImage children. DependencyPropertyHelper.Register<MapOverlaysPanel, IEnumerable<string>>(nameof(SourcePaths), null,
/// </summary> async (control, oldValue, newValue) => await control.SourcePathsPropertyChanged(oldValue, newValue));
public partial class MapOverlaysPanel : MapPanel
public IEnumerable<string> SourcePaths
{ {
public static readonly DependencyProperty SourcePathsProperty = get => (IEnumerable<string>)GetValue(SourcePathsProperty);
DependencyPropertyHelper.Register<MapOverlaysPanel, IEnumerable<string>>(nameof(SourcePaths), null, set => SetValue(SourcePathsProperty, value);
async (control, oldValue, newValue) => await control.SourcePathsPropertyChanged(oldValue, newValue)); }
public IEnumerable<string> SourcePaths private async Task SourcePathsPropertyChanged(IEnumerable<string> oldSourcePaths, IEnumerable<string> newSourcePaths)
{
Children.Clear();
if (oldSourcePaths is INotifyCollectionChanged oldCollection)
{ {
get => (IEnumerable<string>)GetValue(SourcePathsProperty); oldCollection.CollectionChanged -= SourcePathsCollectionChanged;
set => SetValue(SourcePathsProperty, value);
} }
private async Task SourcePathsPropertyChanged(IEnumerable<string> oldSourcePaths, IEnumerable<string> newSourcePaths) if (newSourcePaths != null)
{ {
Children.Clear(); if (newSourcePaths is INotifyCollectionChanged newCollection)
if (oldSourcePaths is INotifyCollectionChanged oldCollection)
{ {
oldCollection.CollectionChanged -= SourcePathsCollectionChanged; newCollection.CollectionChanged += SourcePathsCollectionChanged;
} }
if (newSourcePaths != null) await AddOverlays(0, newSourcePaths);
{
if (newSourcePaths is INotifyCollectionChanged newCollection)
{
newCollection.CollectionChanged += SourcePathsCollectionChanged;
}
await AddOverlays(0, newSourcePaths);
}
}
private async void SourcePathsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
await AddOverlays(e.NewStartingIndex, e.NewItems.Cast<string>());
break;
case NotifyCollectionChangedAction.Remove:
RemoveOverlays(e.OldStartingIndex, e.OldItems.Count);
break;
case NotifyCollectionChangedAction.Move:
RemoveOverlays(e.OldStartingIndex, e.OldItems.Count);
await AddOverlays(e.NewStartingIndex, e.NewItems.Cast<string>());
break;
case NotifyCollectionChangedAction.Replace:
await ReplaceOverlays(e.NewStartingIndex, e.NewItems.Cast<string>());
break;
case NotifyCollectionChangedAction.Reset:
Children.Clear();
await AddOverlays(0, SourcePaths);
break;
}
}
private async Task AddOverlays(int index, IEnumerable<string> sourcePaths)
{
foreach (var sourcePath in sourcePaths)
{
Children.Insert(index++, await CreateOverlayAsync(sourcePath));
}
}
private async Task ReplaceOverlays(int index, IEnumerable<string> sourcePaths)
{
foreach (var sourcePath in sourcePaths)
{
Children[index++] = await CreateOverlayAsync(sourcePath);
}
}
private void RemoveOverlays(int index, int count)
{
while (--count >= 0)
{
Children.RemoveAt(index);
}
}
protected virtual async Task<FrameworkElement> CreateOverlayAsync(string sourcePath)
{
FrameworkElement overlay;
var ext = Path.GetExtension(sourcePath).ToLower();
if (ext == ".kmz" || ext == ".kml")
{
overlay = await GroundOverlay.CreateAsync(sourcePath);
}
else
{
overlay = await GeoImage.CreateAsync(sourcePath);
}
return overlay;
} }
} }
private async void SourcePathsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
await AddOverlays(e.NewStartingIndex, e.NewItems.Cast<string>());
break;
case NotifyCollectionChangedAction.Remove:
RemoveOverlays(e.OldStartingIndex, e.OldItems.Count);
break;
case NotifyCollectionChangedAction.Move:
RemoveOverlays(e.OldStartingIndex, e.OldItems.Count);
await AddOverlays(e.NewStartingIndex, e.NewItems.Cast<string>());
break;
case NotifyCollectionChangedAction.Replace:
await ReplaceOverlays(e.NewStartingIndex, e.NewItems.Cast<string>());
break;
case NotifyCollectionChangedAction.Reset:
Children.Clear();
await AddOverlays(0, SourcePaths);
break;
}
}
private async Task AddOverlays(int index, IEnumerable<string> sourcePaths)
{
foreach (var sourcePath in sourcePaths)
{
Children.Insert(index++, await CreateOverlayAsync(sourcePath));
}
}
private async Task ReplaceOverlays(int index, IEnumerable<string> sourcePaths)
{
foreach (var sourcePath in sourcePaths)
{
Children[index++] = await CreateOverlayAsync(sourcePath);
}
}
private void RemoveOverlays(int index, int count)
{
while (--count >= 0)
{
Children.RemoveAt(index);
}
}
protected virtual async Task<FrameworkElement> CreateOverlayAsync(string sourcePath)
{
FrameworkElement overlay;
var ext = Path.GetExtension(sourcePath).ToLower();
if (ext == ".kmz" || ext == ".kml")
{
overlay = await GroundOverlay.CreateAsync(sourcePath);
}
else
{
overlay = await GeoImage.CreateAsync(sourcePath);
}
return overlay;
}
} }

View file

@ -24,402 +24,401 @@ using Avalonia.Media;
/// Arranges child elements on a Map at positions specified by the attached property Location, /// Arranges child elements on a Map at positions specified by the attached property Location,
/// or in rectangles specified by the attached property BoundingBox. /// or in rectangles specified by the attached property BoundingBox.
/// </summary> /// </summary>
namespace MapControl namespace MapControl;
/// <summary>
/// Optional interface to hold the value of the attached property MapPanel.ParentMap.
/// </summary>
public interface IMapElement
{ {
/// <summary> MapBase ParentMap { get; set; }
/// Optional interface to hold the value of the attached property MapPanel.ParentMap. }
/// </summary>
public interface IMapElement public partial class MapPanel : Panel, IMapElement
{
private static readonly DependencyProperty ViewPositionProperty =
DependencyPropertyHelper.RegisterAttached<Point?>("ViewPosition", typeof(MapPanel));
private static readonly DependencyProperty ParentMapProperty =
DependencyPropertyHelper.RegisterAttached<MapBase>("ParentMap", typeof(MapPanel), null,
(element, oldValue, newValue) =>
{
if (element is IMapElement mapElement)
{
mapElement.ParentMap = newValue;
}
}
#if WPF || AVALONIA
, true // inherits, not available in WinUI/UWP
#endif
);
public MapPanel()
{ {
MapBase ParentMap { get; set; } if (this is MapBase)
{
FlowDirection = FlowDirection.LeftToRight;
SetValue(ParentMapProperty, this);
}
#if UWP || WINUI
else
{
InitMapElement(this);
}
#endif
} }
public partial class MapPanel : Panel, IMapElement private MapBase parentMap;
/// <summary>
/// Implements IMapElement.ParentMap.
/// </summary>
public MapBase ParentMap
{ {
private static readonly DependencyProperty ViewPositionProperty = get => parentMap;
DependencyPropertyHelper.RegisterAttached<Point?>("ViewPosition", typeof(MapPanel)); set => SetParentMap(value);
}
private static readonly DependencyProperty ParentMapProperty = /// <summary>
DependencyPropertyHelper.RegisterAttached<MapBase>("ParentMap", typeof(MapPanel), null, /// Gets a value that controls whether an element's Visibility is automatically
(element, oldValue, newValue) => /// set to Collapsed when it is located outside the visible viewport area.
{ /// </summary>
if (element is IMapElement mapElement) public static bool GetAutoCollapse(FrameworkElement element)
{ {
mapElement.ParentMap = newValue; return (bool)element.GetValue(AutoCollapseProperty);
} }
}
#if WPF || AVALONIA
, true // inherits, not available in WinUI/UWP
#endif
);
public MapPanel() /// <summary>
/// Sets the AutoCollapse property.
/// </summary>
public static void SetAutoCollapse(FrameworkElement element, bool value)
{
element.SetValue(AutoCollapseProperty, value);
}
/// <summary>
/// Gets the Location of an element.
/// </summary>
public static Location GetLocation(FrameworkElement element)
{
return (Location)element.GetValue(LocationProperty);
}
/// <summary>
/// Sets the Location of an element.
/// </summary>
public static void SetLocation(FrameworkElement element, Location value)
{
element.SetValue(LocationProperty, value);
}
/// <summary>
/// Gets the BoundingBox of an element.
/// </summary>
public static BoundingBox GetBoundingBox(FrameworkElement element)
{
return (BoundingBox)element.GetValue(BoundingBoxProperty);
}
/// <summary>
/// Sets the BoundingBox of an element.
/// </summary>
public static void SetBoundingBox(FrameworkElement element, BoundingBox value)
{
element.SetValue(BoundingBoxProperty, value);
}
/// <summary>
/// Gets the MapRect of an element.
/// </summary>
public static Rect? GetMapRect(FrameworkElement element)
{
return (Rect?)element.GetValue(MapRectProperty);
}
/// <summary>
/// Sets the MapRect of an element.
/// </summary>
public static void SetMapRect(FrameworkElement element, Rect? value)
{
element.SetValue(MapRectProperty, value);
}
/// <summary>
/// Gets the view position of an element with Location.
/// </summary>
public static Point? GetViewPosition(FrameworkElement element)
{
return (Point?)element.GetValue(ViewPositionProperty);
}
/// <summary>
/// Sets the attached ViewPosition property of an element. The method is called during
/// ArrangeOverride and may be overridden to modify the actual view position value.
/// An overridden method should call this method to set the attached property.
/// </summary>
protected virtual Point SetViewPosition(FrameworkElement element, Point position)
{
element.SetValue(ViewPositionProperty, position);
return position;
}
protected virtual void SetParentMap(MapBase map)
{
if (parentMap != null && parentMap != this)
{ {
if (this is MapBase) parentMap.ViewportChanged -= OnViewportChanged;
{
FlowDirection = FlowDirection.LeftToRight;
SetValue(ParentMapProperty, this);
}
#if UWP || WINUI
else
{
InitMapElement(this);
}
#endif
} }
private MapBase parentMap; parentMap = map;
/// <summary> if (parentMap != null && parentMap != this)
/// Implements IMapElement.ParentMap.
/// </summary>
public MapBase ParentMap
{ {
get => parentMap; parentMap.ViewportChanged += OnViewportChanged;
set => SetParentMap(value);
OnViewportChanged(new ViewportChangedEventArgs());
}
}
private void OnViewportChanged(object sender, ViewportChangedEventArgs e)
{
OnViewportChanged(e);
}
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.Cast<FrameworkElement>())
{
element.Measure(availableSize);
} }
/// <summary> return new Size();
/// Gets a value that controls whether an element's Visibility is automatically }
/// set to Collapsed when it is located outside the visible viewport area.
/// </summary> protected override Size ArrangeOverride(Size finalSize)
public static bool GetAutoCollapse(FrameworkElement element) {
if (parentMap != null)
{ {
return (bool)element.GetValue(AutoCollapseProperty);
}
/// <summary>
/// Sets the AutoCollapse property.
/// </summary>
public static void SetAutoCollapse(FrameworkElement element, bool value)
{
element.SetValue(AutoCollapseProperty, value);
}
/// <summary>
/// Gets the Location of an element.
/// </summary>
public static Location GetLocation(FrameworkElement element)
{
return (Location)element.GetValue(LocationProperty);
}
/// <summary>
/// Sets the Location of an element.
/// </summary>
public static void SetLocation(FrameworkElement element, Location value)
{
element.SetValue(LocationProperty, value);
}
/// <summary>
/// Gets the BoundingBox of an element.
/// </summary>
public static BoundingBox GetBoundingBox(FrameworkElement element)
{
return (BoundingBox)element.GetValue(BoundingBoxProperty);
}
/// <summary>
/// Sets the BoundingBox of an element.
/// </summary>
public static void SetBoundingBox(FrameworkElement element, BoundingBox value)
{
element.SetValue(BoundingBoxProperty, value);
}
/// <summary>
/// Gets the MapRect of an element.
/// </summary>
public static Rect? GetMapRect(FrameworkElement element)
{
return (Rect?)element.GetValue(MapRectProperty);
}
/// <summary>
/// Sets the MapRect of an element.
/// </summary>
public static void SetMapRect(FrameworkElement element, Rect? value)
{
element.SetValue(MapRectProperty, value);
}
/// <summary>
/// Gets the view position of an element with Location.
/// </summary>
public static Point? GetViewPosition(FrameworkElement element)
{
return (Point?)element.GetValue(ViewPositionProperty);
}
/// <summary>
/// Sets the attached ViewPosition property of an element. The method is called during
/// ArrangeOverride and may be overridden to modify the actual view position value.
/// An overridden method should call this method to set the attached property.
/// </summary>
protected virtual Point SetViewPosition(FrameworkElement element, Point position)
{
element.SetValue(ViewPositionProperty, position);
return position;
}
protected virtual void SetParentMap(MapBase map)
{
if (parentMap != null && parentMap != this)
{
parentMap.ViewportChanged -= OnViewportChanged;
}
parentMap = map;
if (parentMap != null && parentMap != this)
{
parentMap.ViewportChanged += OnViewportChanged;
OnViewportChanged(new ViewportChangedEventArgs());
}
}
private void OnViewportChanged(object sender, ViewportChangedEventArgs e)
{
OnViewportChanged(e);
}
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.Cast<FrameworkElement>()) foreach (var element in Children.Cast<FrameworkElement>())
{ {
element.Measure(availableSize); ArrangeChildElement(element, finalSize);
} }
return new Size();
} }
protected override Size ArrangeOverride(Size finalSize) return finalSize;
{ }
if (parentMap != null)
{
foreach (var element in Children.Cast<FrameworkElement>())
{
ArrangeChildElement(element, finalSize);
}
}
return finalSize; private Point GetViewPosition(Location location)
{
var position = parentMap.LocationToView(location);
if (parentMap.MapProjection.IsNormalCylindrical && !parentMap.InsideViewBounds(position))
{
var longitude = parentMap.NearestLongitude(location.Longitude);
if (!location.LongitudeEquals(longitude))
{
position = parentMap.LocationToView(location.Latitude, longitude);
}
} }
private Point GetViewPosition(Location location) return position;
}
private Rect GetViewRect(Rect mapRect)
{
var center = new Point(mapRect.X + mapRect.Width / 2d, mapRect.Y + mapRect.Height / 2d);
var position = parentMap.ViewTransform.MapToView(center);
if (parentMap.MapProjection.IsNormalCylindrical && !parentMap.InsideViewBounds(position))
{ {
var position = parentMap.LocationToView(location); var location = parentMap.MapProjection.MapToLocation(center);
var longitude = parentMap.NearestLongitude(location.Longitude);
if (parentMap.MapProjection.IsNormalCylindrical && !parentMap.InsideViewBounds(position)) if (!location.LongitudeEquals(longitude))
{ {
var longitude = parentMap.NearestLongitude(location.Longitude); position = parentMap.LocationToView(location.Latitude, longitude);
if (!location.LongitudeEquals(longitude))
{
position = parentMap.LocationToView(location.Latitude, longitude);
}
} }
return position;
} }
private Rect GetViewRect(Rect mapRect) 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 Rect(x, y, width, height);
}
private void ArrangeChildElement(FrameworkElement element, Size panelSize)
{
var location = GetLocation(element);
if (location != null)
{ {
var center = new Point(mapRect.X + mapRect.Width / 2d, mapRect.Y + mapRect.Height / 2d); var position = SetViewPosition(element, GetViewPosition(location));
var position = parentMap.ViewTransform.MapToView(center);
if (parentMap.MapProjection.IsNormalCylindrical && !parentMap.InsideViewBounds(position)) if (GetAutoCollapse(element))
{ {
var location = parentMap.MapProjection.MapToLocation(center); element.SetVisible(parentMap.InsideViewBounds(position));
var longitude = parentMap.NearestLongitude(location.Longitude);
if (!location.LongitudeEquals(longitude))
{
position = parentMap.LocationToView(location.Latitude, longitude);
}
} }
var width = mapRect.Width * parentMap.ViewTransform.Scale; ArrangeElement(element, position);
var height = mapRect.Height * parentMap.ViewTransform.Scale;
var x = position.X - width / 2d;
var y = position.Y - height / 2d;
return new Rect(x, y, width, height);
} }
else
private void ArrangeChildElement(FrameworkElement element, Size panelSize)
{ {
var location = GetLocation(element); element.ClearValue(ViewPositionProperty);
if (location != null) var mapRect = GetMapRect(element);
if (mapRect.HasValue)
{ {
var position = SetViewPosition(element, GetViewPosition(location)); ArrangeElement(element, mapRect.Value, 0d);
if (GetAutoCollapse(element))
{
element.SetVisible(parentMap.InsideViewBounds(position));
}
ArrangeElement(element, position);
} }
else else
{ {
element.ClearValue(ViewPositionProperty); var boundingBox = GetBoundingBox(element);
var mapRect = GetMapRect(element); if (boundingBox != null)
if (mapRect.HasValue)
{ {
ArrangeElement(element, mapRect.Value, 0d); (var rect, var rotation) = parentMap.MapProjection.BoundingBoxToMap(boundingBox);
ArrangeElement(element, rect, -rotation);
} }
else else
{ {
var boundingBox = GetBoundingBox(element); ArrangeElement(element, panelSize);
if (boundingBox != null)
{
(var rect, var rotation) = parentMap.MapProjection.BoundingBoxToMap(boundingBox);
ArrangeElement(element, rect, -rotation);
}
else
{
ArrangeElement(element, panelSize);
}
} }
} }
} }
}
private void ArrangeElement(FrameworkElement element, Rect mapRect, double rotation) private void ArrangeElement(FrameworkElement element, Rect mapRect, double rotation)
{
var viewRect = GetViewRect(mapRect);
element.Width = viewRect.Width;
element.Height = viewRect.Height;
element.Arrange(viewRect);
rotation += parentMap.ViewTransform.Rotation;
if (element.RenderTransform is RotateTransform rotateTransform)
{ {
var viewRect = GetViewRect(mapRect); rotateTransform.Angle = rotation;
element.Width = viewRect.Width;
element.Height = viewRect.Height;
element.Arrange(viewRect);
rotation += parentMap.ViewTransform.Rotation;
if (element.RenderTransform is RotateTransform rotateTransform)
{
rotateTransform.Angle = rotation;
}
else if (rotation != 0d)
{
element.SetRenderTransform(new RotateTransform { Angle = rotation }, true);
}
} }
else if (rotation != 0d)
private static void ArrangeElement(FrameworkElement element, Point position)
{ {
var size = GetDesiredSize(element); element.SetRenderTransform(new RotateTransform { Angle = rotation }, true);
var x = position.X;
var y = position.Y;
switch (element.HorizontalAlignment)
{
case HorizontalAlignment.Center:
x -= size.Width / 2d;
break;
case HorizontalAlignment.Right:
x -= size.Width;
break;
default:
break;
}
switch (element.VerticalAlignment)
{
case VerticalAlignment.Center:
y -= size.Height / 2d;
break;
case VerticalAlignment.Bottom:
y -= size.Height;
break;
default:
break;
}
element.Arrange(new Rect(x, y, size.Width, size.Height));
}
private static void ArrangeElement(FrameworkElement element, Size panelSize)
{
var size = GetDesiredSize(element);
var x = 0d;
var y = 0d;
var width = size.Width;
var height = size.Height;
switch (element.HorizontalAlignment)
{
case HorizontalAlignment.Center:
x = (panelSize.Width - size.Width) / 2d;
break;
case HorizontalAlignment.Right:
x = panelSize.Width - size.Width;
break;
case HorizontalAlignment.Stretch:
width = panelSize.Width;
break;
default:
break;
}
switch (element.VerticalAlignment)
{
case VerticalAlignment.Center:
y = (panelSize.Height - size.Height) / 2d;
break;
case VerticalAlignment.Bottom:
y = panelSize.Height - size.Height;
break;
case VerticalAlignment.Stretch:
height = panelSize.Height;
break;
default:
break;
}
element.Arrange(new Rect(x, y, width, height));
}
private static Size GetDesiredSize(FrameworkElement element)
{
var width = element.DesiredSize.Width;
var height = element.DesiredSize.Height;
if (width < 0d || width == double.PositiveInfinity)
{
width = 0d;
}
if (height < 0d || height == double.PositiveInfinity)
{
height = 0d;
}
return new Size(width, height);
} }
} }
private static void ArrangeElement(FrameworkElement element, Point position)
{
var size = GetDesiredSize(element);
var x = position.X;
var y = position.Y;
switch (element.HorizontalAlignment)
{
case HorizontalAlignment.Center:
x -= size.Width / 2d;
break;
case HorizontalAlignment.Right:
x -= size.Width;
break;
default:
break;
}
switch (element.VerticalAlignment)
{
case VerticalAlignment.Center:
y -= size.Height / 2d;
break;
case VerticalAlignment.Bottom:
y -= size.Height;
break;
default:
break;
}
element.Arrange(new Rect(x, y, size.Width, size.Height));
}
private static void ArrangeElement(FrameworkElement element, Size panelSize)
{
var size = GetDesiredSize(element);
var x = 0d;
var y = 0d;
var width = size.Width;
var height = size.Height;
switch (element.HorizontalAlignment)
{
case HorizontalAlignment.Center:
x = (panelSize.Width - size.Width) / 2d;
break;
case HorizontalAlignment.Right:
x = panelSize.Width - size.Width;
break;
case HorizontalAlignment.Stretch:
width = panelSize.Width;
break;
default:
break;
}
switch (element.VerticalAlignment)
{
case VerticalAlignment.Center:
y = (panelSize.Height - size.Height) / 2d;
break;
case VerticalAlignment.Bottom:
y = panelSize.Height - size.Height;
break;
case VerticalAlignment.Stretch:
height = panelSize.Height;
break;
default:
break;
}
element.Arrange(new Rect(x, y, width, height));
}
private static Size GetDesiredSize(FrameworkElement element)
{
var width = element.DesiredSize.Width;
var height = element.DesiredSize.Height;
if (width < 0d || width == double.PositiveInfinity)
{
width = 0d;
}
if (height < 0d || height == double.PositiveInfinity)
{
height = 0d;
}
return new Size(width, height);
}
} }

View file

@ -12,105 +12,104 @@ using Avalonia;
using Avalonia.Media; using Avalonia.Media;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// A path element with a Data property that holds a Geometry in view coordinates or
/// projected map coordinates that are relative to an origin Location.
/// </summary>
public partial class MapPath : IMapElement
{ {
public static readonly DependencyProperty LocationProperty =
DependencyPropertyHelper.Register<MapPath, Location>(nameof(Location), null,
(path, oldValue, newValue) => path.UpdateData());
/// <summary> /// <summary>
/// A path element with a Data property that holds a Geometry in view coordinates or /// Gets or sets a Location that is either used as
/// projected map coordinates that are relative to an origin Location. /// - the origin point of a geometry specified in projected map coordinates (meters) or
/// - as an optional anchor point to constrain the view position of MapPaths with
/// multiple Locations (like MapPolyline or MapPolygon) to the visible map viewport,
/// as done for elements where the MapPanel.Location property is set.
/// </summary> /// </summary>
public partial class MapPath : IMapElement public Location Location
{ {
public static readonly DependencyProperty LocationProperty = get => (Location)GetValue(LocationProperty);
DependencyPropertyHelper.Register<MapPath, Location>(nameof(Location), null, set => SetValue(LocationProperty, value);
(path, oldValue, newValue) => path.UpdateData()); }
/// <summary> /// <summary>
/// Gets or sets a Location that is either used as /// Implements IMapElement.ParentMap.
/// - the origin point of a geometry specified in projected map coordinates (meters) or /// </summary>
/// - as an optional anchor point to constrain the view position of MapPaths with public MapBase ParentMap
/// multiple Locations (like MapPolyline or MapPolygon) to the visible map viewport, {
/// as done for elements where the MapPanel.Location property is set. get;
/// </summary> set
public Location Location
{ {
get => (Location)GetValue(LocationProperty); if (field != null)
set => SetValue(LocationProperty, value);
}
/// <summary>
/// Implements IMapElement.ParentMap.
/// </summary>
public MapBase ParentMap
{
get;
set
{ {
if (field != null) field.ViewportChanged -= OnViewportChanged;
{ }
field.ViewportChanged -= OnViewportChanged;
} field = value;
field = value; if (field != null)
{
if (field != null) field.ViewportChanged += OnViewportChanged;
{
field.ViewportChanged += OnViewportChanged;
}
UpdateData();
} }
}
private void OnViewportChanged(object sender, ViewportChangedEventArgs e)
{
UpdateData(); UpdateData();
} }
}
protected void SetDataTransform(Matrix matrix) private void OnViewportChanged(object sender, ViewportChangedEventArgs e)
{ {
if (Data.Transform is MatrixTransform transform UpdateData();
}
protected void SetDataTransform(Matrix matrix)
{
if (Data.Transform is MatrixTransform transform
#if WPF #if WPF
&& !transform.IsFrozen && !transform.IsFrozen
#endif #endif
) )
{
transform.Matrix = matrix;
}
else
{
Data.Transform = new MatrixTransform { Matrix = matrix };
}
}
protected virtual void UpdateData()
{ {
if (Data != null && ParentMap != null && Location != null) transform.Matrix = matrix;
{
SetDataTransform(ParentMap.GetMapToViewTransform(Location));
}
MapPanel.SetLocation(this, Location);
} }
else
protected Point LocationToMap(Location location, double longitudeOffset)
{ {
var point = ParentMap.MapProjection.LocationToMap(location.Latitude, location.Longitude + longitudeOffset); Data.Transform = new MatrixTransform { Matrix = matrix };
if (point.Y == double.PositiveInfinity)
{
point = new Point(point.X, 1e9);
}
else if (point.Y == double.NegativeInfinity)
{
point = new Point(point.X, -1e9);
}
return point;
}
protected Point LocationToView(Location location, double longitudeOffset)
{
return ParentMap.ViewTransform.MapToView(LocationToMap(location, longitudeOffset));
} }
} }
protected virtual void UpdateData()
{
if (Data != null && ParentMap != null && Location != null)
{
SetDataTransform(ParentMap.GetMapToViewTransform(Location));
}
MapPanel.SetLocation(this, Location);
}
protected Point LocationToMap(Location location, double longitudeOffset)
{
var point = ParentMap.MapProjection.LocationToMap(location.Latitude, location.Longitude + longitudeOffset);
if (point.Y == double.PositiveInfinity)
{
point = new Point(point.X, 1e9);
}
else if (point.Y == double.NegativeInfinity)
{
point = new Point(point.X, -1e9);
}
return point;
}
protected Point LocationToView(Location location, double longitudeOffset)
{
return ParentMap.ViewTransform.MapToView(LocationToMap(location, longitudeOffset));
}
} }

View file

@ -7,32 +7,31 @@ using Windows.UI.Xaml;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// A polygon defined by a collection of Locations.
/// </summary>
public partial class MapPolygon : MapPolypoint
{ {
public static readonly DependencyProperty LocationsProperty =
DependencyPropertyHelper.Register<MapPolygon, IEnumerable<Location>>(nameof(Locations), null,
(polygon, oldValue, newValue) => polygon.DataCollectionPropertyChanged(oldValue, newValue));
/// <summary> /// <summary>
/// A polygon defined by a collection of Locations. /// Gets or sets the Locations that define the polygon points.
/// </summary> /// </summary>
public partial class MapPolygon : MapPolypoint
{
public static readonly DependencyProperty LocationsProperty =
DependencyPropertyHelper.Register<MapPolygon, IEnumerable<Location>>(nameof(Locations), null,
(polygon, oldValue, newValue) => polygon.DataCollectionPropertyChanged(oldValue, newValue));
/// <summary>
/// Gets or sets the Locations that define the polygon points.
/// </summary>
#if WPF #if WPF
[System.ComponentModel.TypeConverter(typeof(LocationCollectionConverter))] [System.ComponentModel.TypeConverter(typeof(LocationCollectionConverter))]
#endif #endif
public IEnumerable<Location> Locations public IEnumerable<Location> Locations
{ {
get => (IEnumerable<Location>)GetValue(LocationsProperty); get => (IEnumerable<Location>)GetValue(LocationsProperty);
set => SetValue(LocationsProperty, value); set => SetValue(LocationsProperty, value);
} }
protected override void UpdateData() protected override void UpdateData()
{ {
UpdateData(Locations, true); UpdateData(Locations, true);
}
} }
} }

View file

@ -7,32 +7,31 @@ using Windows.UI.Xaml;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// A polyline defined by a collection of Locations.
/// </summary>
public partial class MapPolyline : MapPolypoint
{ {
public static readonly DependencyProperty LocationsProperty =
DependencyPropertyHelper.Register<MapPolyline, IEnumerable<Location>>(nameof(Locations), null,
(polyline, oldValue, newValue) => polyline.DataCollectionPropertyChanged(oldValue, newValue));
/// <summary> /// <summary>
/// A polyline defined by a collection of Locations. /// Gets or sets the Locations that define the polyline points.
/// </summary> /// </summary>
public partial class MapPolyline : MapPolypoint
{
public static readonly DependencyProperty LocationsProperty =
DependencyPropertyHelper.Register<MapPolyline, IEnumerable<Location>>(nameof(Locations), null,
(polyline, oldValue, newValue) => polyline.DataCollectionPropertyChanged(oldValue, newValue));
/// <summary>
/// Gets or sets the Locations that define the polyline points.
/// </summary>
#if WPF #if WPF
[System.ComponentModel.TypeConverter(typeof(LocationCollectionConverter))] [System.ComponentModel.TypeConverter(typeof(LocationCollectionConverter))]
#endif #endif
public IEnumerable<Location> Locations public IEnumerable<Location> Locations
{ {
get => (IEnumerable<Location>)GetValue(LocationsProperty); get => (IEnumerable<Location>)GetValue(LocationsProperty);
set => SetValue(LocationsProperty, value); set => SetValue(LocationsProperty, value);
} }
protected override void UpdateData() protected override void UpdateData()
{ {
UpdateData(Locations, false); UpdateData(Locations, false);
}
} }
} }

View file

@ -20,64 +20,63 @@ using Avalonia.Media;
using PolypointGeometry = Avalonia.Media.PathGeometry; using PolypointGeometry = Avalonia.Media.PathGeometry;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Base class of MapPolyline and MapPolygon and MapMultiPolygon.
/// </summary>
public partial class MapPolypoint
{ {
/// <summary> public static readonly DependencyProperty FillRuleProperty =
/// Base class of MapPolyline and MapPolygon and MapMultiPolygon. DependencyPropertyHelper.Register<MapPolygon, FillRule>(nameof(FillRule), FillRule.EvenOdd,
/// </summary> (polypoint, oldValue, newValue) => ((PolypointGeometry)polypoint.Data).FillRule = newValue);
public partial class MapPolypoint
public FillRule FillRule
{ {
public static readonly DependencyProperty FillRuleProperty = get => (FillRule)GetValue(FillRuleProperty);
DependencyPropertyHelper.Register<MapPolygon, FillRule>(nameof(FillRule), FillRule.EvenOdd, set => SetValue(FillRuleProperty, value);
(polypoint, oldValue, newValue) => ((PolypointGeometry)polypoint.Data).FillRule = newValue); }
public FillRule FillRule protected MapPolypoint()
{
Data = new PolypointGeometry();
}
protected void DataCollectionPropertyChanged(IEnumerable oldValue, IEnumerable newValue)
{
if (oldValue is INotifyCollectionChanged oldCollection)
{ {
get => (FillRule)GetValue(FillRuleProperty); oldCollection.CollectionChanged -= DataCollectionChanged;
set => SetValue(FillRuleProperty, value);
} }
protected MapPolypoint() if (newValue is INotifyCollectionChanged newCollection)
{ {
Data = new PolypointGeometry(); newCollection.CollectionChanged += DataCollectionChanged;
} }
protected void DataCollectionPropertyChanged(IEnumerable oldValue, IEnumerable newValue) UpdateData();
}
protected void DataCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
UpdateData();
}
protected double GetLongitudeOffset(IEnumerable<Location> locations)
{
var longitudeOffset = 0d;
if (ParentMap.MapProjection.IsNormalCylindrical)
{ {
if (oldValue is INotifyCollectionChanged oldCollection) var location = Location ?? locations?.FirstOrDefault();
if (location != null &&
!ParentMap.InsideViewBounds(ParentMap.LocationToView(location)))
{ {
oldCollection.CollectionChanged -= DataCollectionChanged; longitudeOffset = ParentMap.NearestLongitude(location.Longitude) - location.Longitude;
} }
if (newValue is INotifyCollectionChanged newCollection)
{
newCollection.CollectionChanged += DataCollectionChanged;
}
UpdateData();
} }
protected void DataCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) return longitudeOffset;
{
UpdateData();
}
protected double GetLongitudeOffset(IEnumerable<Location> locations)
{
var longitudeOffset = 0d;
if (ParentMap.MapProjection.IsNormalCylindrical)
{
var location = Location ?? locations?.FirstOrDefault();
if (location != null &&
!ParentMap.InsideViewBounds(ParentMap.LocationToView(location)))
{
longitudeOffset = ParentMap.NearestLongitude(location.Longitude) - location.Longitude;
}
}
return longitudeOffset;
}
} }
} }

View file

@ -6,134 +6,133 @@ using System.Windows.Media;
using Avalonia; using Avalonia;
#endif #endif
namespace MapControl namespace MapControl;
{
/// <summary> /// <summary>
/// Implements a map projection, a transformation between geographic coordinates, /// Implements a map projection, a transformation between geographic coordinates,
/// i.e. latitude and longitude in degrees, and cartesian map coordinates in meters. /// i.e. latitude and longitude in degrees, and cartesian map coordinates in meters.
/// See https://en.wikipedia.org/wiki/Map_projection. /// See https://en.wikipedia.org/wiki/Map_projection.
/// </summary> /// </summary>
#if UWP || WINUI #if UWP || WINUI
[Windows.Foundation.Metadata.CreateFromString(MethodName = "Parse")] [Windows.Foundation.Metadata.CreateFromString(MethodName = "Parse")]
#else #else
[System.ComponentModel.TypeConverter(typeof(MapProjectionConverter))] [System.ComponentModel.TypeConverter(typeof(MapProjectionConverter))]
#endif #endif
public abstract class MapProjection public abstract class MapProjection
{
public const double Wgs84EquatorialRadius = 6378137d;
public const double Wgs84Flattening = 1d / 298.257223563;
public const double Wgs84MeterPerDegree = Wgs84EquatorialRadius * Math.PI / 180d;
public static MapProjectionFactory Factory
{ {
public const double Wgs84EquatorialRadius = 6378137d; get => field ??= new MapProjectionFactory();
public const double Wgs84Flattening = 1d / 298.257223563; set;
public const double Wgs84MeterPerDegree = Wgs84EquatorialRadius * Math.PI / 180d; }
public static MapProjectionFactory Factory /// <summary>
/// Creates a MapProjection instance from a CRS identifier string.
/// </summary>
public static MapProjection Parse(string crsId)
{
return Factory.GetProjection(crsId);
}
public override string ToString() => CrsId;
/// <summary>
/// Gets the WMS 1.3.0 CRS identifier.
/// </summary>
public string CrsId { get; protected set; }
public double EquatorialRadius { get; protected set; } = Wgs84EquatorialRadius;
public double Flattening { get; protected set; } = Wgs84Flattening;
public double ScaleFactor { get; protected set; } = 1d;
public double CentralMeridian { get; protected set; }
public double LatitudeOfOrigin { get; protected set; }
public double FalseEasting { get; protected set; }
public double FalseNorthing { get; protected set; }
public bool IsNormalCylindrical { get; protected set; }
/// <summary>
/// Gets the grid convergence angle in degrees at the specified geographic coordinates.
/// Used for rotating the Rect resulting from BoundingBoxToMap in non-normal-cylindrical
/// projections, i.e. Transverse Mercator and Polar Stereographic.
/// </summary>
public virtual double GridConvergence(double latitude, double longitude) => 0d;
/// <summary>
/// Gets the relative transform at the specified geographic coordinates.
/// The returned Matrix represents the local relative scale and rotation.
/// </summary>
public virtual Matrix RelativeTransform(double latitude, double longitude)
{
var transform = new Matrix(ScaleFactor, 0d, 0d, ScaleFactor, 0d, 0d);
transform.Rotate(-GridConvergence(latitude, longitude));
return transform;
}
/// <summary>
/// Transforms geographic coordinates to a Point in projected map coordinates.
/// </summary>
public abstract Point LocationToMap(double latitude, double longitude);
/// <summary>
/// Transforms projected map coordinates to a Location in geographic coordinates.
/// </summary>
public abstract Location MapToLocation(double x, double y);
/// <summary>
/// Gets the relative transform at the specified geographic Location.
/// </summary>
public Matrix RelativeTransform(Location location) => RelativeTransform(location.Latitude, location.Longitude);
/// <summary>
/// Transforms a Location in geographic coordinates to a Point in projected map coordinates.
/// </summary>
public Point LocationToMap(Location location) => LocationToMap(location.Latitude, location.Longitude);
/// <summary>
/// Transforms a Point in projected map coordinates to a Location in geographic coordinates.
/// </summary>
public Location MapToLocation(Point point) => MapToLocation(point.X, point.Y);
/// <summary>
/// Transforms a BoundingBox in geographic coordinates to a Rect in projected map coordinates
/// with an optional rotation angle in degrees for non-normal-cylindrical projections.
/// </summary>
public (Rect, double) BoundingBoxToMap(BoundingBox boundingBox)
{
Rect rect;
var rotation = 0d;
var southWest = LocationToMap(boundingBox.South, boundingBox.West);
var northEast = LocationToMap(boundingBox.North, boundingBox.East);
if (IsNormalCylindrical)
{ {
get => field ??= new MapProjectionFactory(); rect = new Rect(southWest.X, southWest.Y, northEast.X - southWest.X, northEast.Y - southWest.Y);
set; }
else
{
var southEast = LocationToMap(boundingBox.South, boundingBox.East);
var northWest = LocationToMap(boundingBox.North, boundingBox.West);
var west = new Point((southWest.X + northWest.X) / 2d, (southWest.Y + northWest.Y) / 2d);
var east = new Point((southEast.X + northEast.X) / 2d, (southEast.Y + northEast.Y) / 2d);
var south = new Point((southWest.X + southEast.X) / 2d, (southWest.Y + southEast.Y) / 2d);
var north = new Point((northWest.X + northEast.X) / 2d, (northWest.Y + northEast.Y) / 2d);
var dxWidth = east.X - west.X;
var dyWidth = east.Y - west.Y;
var dxHeight = north.X - south.X;
var dyHeight = north.Y - south.Y;
var width = Math.Sqrt(dxWidth * dxWidth + dyWidth * dyWidth);
var height = Math.Sqrt(dxHeight * dxHeight + dyHeight * dyHeight);
var x = (south.X + north.X - width) / 2d; // cx-w/2
var y = (south.Y + north.Y - height) / 2d; // cy-h/2
rect = new Rect(x, y, width, height);
rotation = Math.Atan2(-dxHeight, dyHeight) * 180d / Math.PI;
} }
/// <summary> return (rect, rotation);
/// Creates a MapProjection instance from a CRS identifier string.
/// </summary>
public static MapProjection Parse(string crsId)
{
return Factory.GetProjection(crsId);
}
public override string ToString() => CrsId;
/// <summary>
/// Gets the WMS 1.3.0 CRS identifier.
/// </summary>
public string CrsId { get; protected set; }
public double EquatorialRadius { get; protected set; } = Wgs84EquatorialRadius;
public double Flattening { get; protected set; } = Wgs84Flattening;
public double ScaleFactor { get; protected set; } = 1d;
public double CentralMeridian { get; protected set; }
public double LatitudeOfOrigin { get; protected set; }
public double FalseEasting { get; protected set; }
public double FalseNorthing { get; protected set; }
public bool IsNormalCylindrical { get; protected set; }
/// <summary>
/// Gets the grid convergence angle in degrees at the specified geographic coordinates.
/// Used for rotating the Rect resulting from BoundingBoxToMap in non-normal-cylindrical
/// projections, i.e. Transverse Mercator and Polar Stereographic.
/// </summary>
public virtual double GridConvergence(double latitude, double longitude) => 0d;
/// <summary>
/// Gets the relative transform at the specified geographic coordinates.
/// The returned Matrix represents the local relative scale and rotation.
/// </summary>
public virtual Matrix RelativeTransform(double latitude, double longitude)
{
var transform = new Matrix(ScaleFactor, 0d, 0d, ScaleFactor, 0d, 0d);
transform.Rotate(-GridConvergence(latitude, longitude));
return transform;
}
/// <summary>
/// Transforms geographic coordinates to a Point in projected map coordinates.
/// </summary>
public abstract Point LocationToMap(double latitude, double longitude);
/// <summary>
/// Transforms projected map coordinates to a Location in geographic coordinates.
/// </summary>
public abstract Location MapToLocation(double x, double y);
/// <summary>
/// Gets the relative transform at the specified geographic Location.
/// </summary>
public Matrix RelativeTransform(Location location) => RelativeTransform(location.Latitude, location.Longitude);
/// <summary>
/// Transforms a Location in geographic coordinates to a Point in projected map coordinates.
/// </summary>
public Point LocationToMap(Location location) => LocationToMap(location.Latitude, location.Longitude);
/// <summary>
/// Transforms a Point in projected map coordinates to a Location in geographic coordinates.
/// </summary>
public Location MapToLocation(Point point) => MapToLocation(point.X, point.Y);
/// <summary>
/// Transforms a BoundingBox in geographic coordinates to a Rect in projected map coordinates
/// with an optional rotation angle in degrees for non-normal-cylindrical projections.
/// </summary>
public (Rect, double) BoundingBoxToMap(BoundingBox boundingBox)
{
Rect rect;
var rotation = 0d;
var southWest = LocationToMap(boundingBox.South, boundingBox.West);
var northEast = LocationToMap(boundingBox.North, boundingBox.East);
if (IsNormalCylindrical)
{
rect = new Rect(southWest.X, southWest.Y, northEast.X - southWest.X, northEast.Y - southWest.Y);
}
else
{
var southEast = LocationToMap(boundingBox.South, boundingBox.East);
var northWest = LocationToMap(boundingBox.North, boundingBox.West);
var west = new Point((southWest.X + northWest.X) / 2d, (southWest.Y + northWest.Y) / 2d);
var east = new Point((southEast.X + northEast.X) / 2d, (southEast.Y + northEast.Y) / 2d);
var south = new Point((southWest.X + southEast.X) / 2d, (southWest.Y + southEast.Y) / 2d);
var north = new Point((northWest.X + northEast.X) / 2d, (northWest.Y + northEast.Y) / 2d);
var dxWidth = east.X - west.X;
var dyWidth = east.Y - west.Y;
var dxHeight = north.X - south.X;
var dyHeight = north.Y - south.Y;
var width = Math.Sqrt(dxWidth * dxWidth + dyWidth * dyWidth);
var height = Math.Sqrt(dxHeight * dxHeight + dyHeight * dyHeight);
var x = (south.X + north.X - width) / 2d; // cx-w/2
var y = (south.Y + north.Y - height) / 2d; // cy-h/2
rect = new Rect(x, y, width, height);
rotation = Math.Atan2(-dxHeight, dyHeight) * 180d / Math.PI;
}
return (rect, rotation);
}
} }
} }

View file

@ -1,57 +1,56 @@
using System; using System;
namespace MapControl namespace MapControl;
public class MapProjectionFactory
{ {
public class MapProjectionFactory public MapProjection GetProjection(string crsId)
{ {
public MapProjection GetProjection(string crsId) var projection = CreateProjection(crsId);
if (projection == null &&
crsId.StartsWith("EPSG:") &&
int.TryParse(crsId.Substring(5), out int epsgCode))
{ {
var projection = CreateProjection(crsId); projection = CreateProjection(epsgCode);
if (projection == null &&
crsId.StartsWith("EPSG:") &&
int.TryParse(crsId.Substring(5), out int epsgCode))
{
projection = CreateProjection(epsgCode);
}
return projection ?? throw new NotSupportedException($"MapProjection \"{crsId}\" is not supported.");
} }
protected virtual MapProjection CreateProjection(string crsId) return projection ?? throw new NotSupportedException($"MapProjection \"{crsId}\" is not supported.");
}
protected virtual MapProjection CreateProjection(string crsId)
{
MapProjection projection = crsId switch
{ {
MapProjection projection = crsId switch WebMercatorProjection.DefaultCrsId => new WebMercatorProjection(),
{ WorldMercatorProjection.DefaultCrsId => new WorldMercatorProjection(),
WebMercatorProjection.DefaultCrsId => new WebMercatorProjection(), Wgs84UpsNorthProjection.DefaultCrsId => new Wgs84UpsNorthProjection(),
WorldMercatorProjection.DefaultCrsId => new WorldMercatorProjection(), Wgs84UpsSouthProjection.DefaultCrsId => new Wgs84UpsSouthProjection(),
Wgs84UpsNorthProjection.DefaultCrsId => new Wgs84UpsNorthProjection(), EquirectangularProjection.DefaultCrsId or "CRS:84" => new EquirectangularProjection(crsId),
Wgs84UpsSouthProjection.DefaultCrsId => new Wgs84UpsSouthProjection(), _ => null
EquirectangularProjection.DefaultCrsId or "CRS:84" => new EquirectangularProjection(crsId), };
_ => null
};
if (projection == null && crsId.StartsWith(StereographicProjection.DefaultCrsId)) if (projection == null && crsId.StartsWith(StereographicProjection.DefaultCrsId))
{ {
projection = new StereographicProjection(crsId); projection = new StereographicProjection(crsId);
}
return projection;
} }
protected virtual MapProjection CreateProjection(int epsgCode) return projection;
}
protected virtual MapProjection CreateProjection(int epsgCode)
{
return epsgCode switch
{ {
return epsgCode switch var c when c is >= Etrs89UtmProjection.FirstZoneEpsgCode
{ and <= Etrs89UtmProjection.LastZoneEpsgCode => new Etrs89UtmProjection(c % 100),
var c when c is >= Etrs89UtmProjection.FirstZoneEpsgCode var c when c is >= Nad83UtmProjection.FirstZoneEpsgCode
and <= Etrs89UtmProjection.LastZoneEpsgCode => new Etrs89UtmProjection(c % 100), and <= Nad83UtmProjection.LastZoneEpsgCode => new Nad83UtmProjection(c % 100),
var c when c is >= Nad83UtmProjection.FirstZoneEpsgCode var c when c is >= Wgs84UtmProjection.FirstZoneNorthEpsgCode
and <= Nad83UtmProjection.LastZoneEpsgCode => new Nad83UtmProjection(c % 100), and <= Wgs84UtmProjection.LastZoneNorthEpsgCode => new Wgs84UtmProjection(c % 100, true),
var c when c is >= Wgs84UtmProjection.FirstZoneNorthEpsgCode var c when c is >= Wgs84UtmProjection.FirstZoneSouthEpsgCode
and <= Wgs84UtmProjection.LastZoneNorthEpsgCode => new Wgs84UtmProjection(c % 100, true), and <= Wgs84UtmProjection.LastZoneSouthEpsgCode => new Wgs84UtmProjection(c % 100, false),
var c when c is >= Wgs84UtmProjection.FirstZoneSouthEpsgCode _ => null
and <= Wgs84UtmProjection.LastZoneSouthEpsgCode => new Wgs84UtmProjection(c % 100, false), };
_ => null
};
}
} }
} }

View file

@ -26,105 +26,104 @@ using Avalonia.Layout;
using PropertyPath = System.String; using PropertyPath = System.String;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Draws a map scale overlay.
/// </summary>
public partial class MapScale : MapPanel
{ {
/// <summary> public static readonly DependencyProperty PaddingProperty =
/// Draws a map scale overlay. DependencyPropertyHelper.Register<MapScale, Thickness>(nameof(Padding), new Thickness(4));
/// </summary>
public partial class MapScale : MapPanel public static readonly DependencyProperty StrokeThicknessProperty =
DependencyPropertyHelper.Register<MapScale, double>(nameof(StrokeThickness), 1d);
public Thickness Padding
{ {
public static readonly DependencyProperty PaddingProperty = get => (Thickness)GetValue(PaddingProperty);
DependencyPropertyHelper.Register<MapScale, Thickness>(nameof(Padding), new Thickness(4)); set => SetValue(PaddingProperty, value);
}
public static readonly DependencyProperty StrokeThicknessProperty = public double StrokeThickness
DependencyPropertyHelper.Register<MapScale, double>(nameof(StrokeThickness), 1d); {
get => (double)GetValue(StrokeThicknessProperty);
set => SetValue(StrokeThicknessProperty, value);
}
public Thickness Padding private readonly Polyline line = new Polyline();
{
get => (Thickness)GetValue(PaddingProperty);
set => SetValue(PaddingProperty, value);
}
public double StrokeThickness private readonly TextBlock label = new TextBlock
{ {
get => (double)GetValue(StrokeThicknessProperty); HorizontalAlignment = HorizontalAlignment.Center,
set => SetValue(StrokeThicknessProperty, value); VerticalAlignment = VerticalAlignment.Center
} };
private readonly Polyline line = new Polyline(); public MapScale()
{
MinWidth = 100d;
Children.Add(line);
Children.Add(label);
}
private readonly TextBlock label = new TextBlock protected override void SetParentMap(MapBase map)
{ {
HorizontalAlignment = HorizontalAlignment.Center, base.SetParentMap(map);
VerticalAlignment = VerticalAlignment.Center
};
public MapScale() line.SetBinding(Shape.StrokeThicknessProperty,
{ new Binding { Source = this, Path = new PropertyPath(nameof(StrokeThickness)) });
MinWidth = 100d;
Children.Add(line);
Children.Add(label);
}
protected override void SetParentMap(MapBase map) line.SetBinding(Shape.StrokeProperty,
{ new Binding { Source = map, Path = new PropertyPath(nameof(MapBase.Foreground)) });
base.SetParentMap(map);
line.SetBinding(Shape.StrokeThicknessProperty,
new Binding { Source = this, Path = new PropertyPath(nameof(StrokeThickness)) });
line.SetBinding(Shape.StrokeProperty,
new Binding { Source = map, Path = new PropertyPath(nameof(MapBase.Foreground)) });
#if UWP || WINUI #if UWP || WINUI
label.SetBinding(TextBlock.ForegroundProperty, label.SetBinding(TextBlock.ForegroundProperty,
new Binding { Source = map, Path = new PropertyPath(nameof(MapBase.Foreground)) }); new Binding { Source = map, Path = new PropertyPath(nameof(MapBase.Foreground)) });
#endif #endif
} }
protected override Size MeasureOverride(Size availableSize) protected override Size MeasureOverride(Size availableSize)
{
var size = new Size();
if (ParentMap != null)
{ {
var size = new Size(); var x0 = ParentMap.ActualWidth / 2d;
var y0 = ParentMap.ActualHeight / 2d;
var p1 = ParentMap.ViewToLocation(new Point(x0 - 50d, y0));
var p2 = ParentMap.ViewToLocation(new Point(x0 + 50d, y0));
var scale = 100d / p1.GetDistance(p2);
var length = MinWidth / scale;
var magnitude = Math.Pow(10d, Math.Floor(Math.Log10(length)));
if (ParentMap != null) length = length / magnitude < 2d ? 2d * magnitude
{ : length / magnitude < 5d ? 5d * magnitude
var x0 = ParentMap.ActualWidth / 2d; : 10d * magnitude;
var y0 = ParentMap.ActualHeight / 2d;
var p1 = ParentMap.ViewToLocation(new Point(x0 - 50d, y0));
var p2 = ParentMap.ViewToLocation(new Point(x0 + 50d, y0));
var scale = 100d / p1.GetDistance(p2);
var length = MinWidth / scale;
var magnitude = Math.Pow(10d, Math.Floor(Math.Log10(length)));
length = length / magnitude < 2d ? 2d * magnitude size = new Size(
: length / magnitude < 5d ? 5d * magnitude length * scale + StrokeThickness + Padding.Left + Padding.Right,
: 10d * magnitude; 1.5 * label.FontSize + 2 * StrokeThickness + Padding.Top + Padding.Bottom);
size = new Size( var x1 = Padding.Left + StrokeThickness / 2d;
length * scale + StrokeThickness + Padding.Left + Padding.Right, var x2 = size.Width - Padding.Right - StrokeThickness / 2d;
1.5 * label.FontSize + 2 * StrokeThickness + Padding.Top + Padding.Bottom); var y1 = size.Height / 2d;
var y2 = size.Height - Padding.Bottom - StrokeThickness / 2d;
var x1 = Padding.Left + StrokeThickness / 2d; line.Points = [new Point(x1, y1), new Point(x1, y2), new Point(x2, y2), new Point(x2, y1)];
var x2 = size.Width - Padding.Right - StrokeThickness / 2d;
var y1 = size.Height / 2d;
var y2 = size.Height - Padding.Bottom - StrokeThickness / 2d;
line.Points = [new Point(x1, y1), new Point(x1, y2), new Point(x2, y2), new Point(x2, y1)]; label.Text = length >= 1000d
? string.Format(CultureInfo.InvariantCulture, "{0:F0} km", length / 1000d)
: string.Format(CultureInfo.InvariantCulture, "{0:F0} m", length);
label.Text = length >= 1000d line.Measure(size);
? string.Format(CultureInfo.InvariantCulture, "{0:F0} km", length / 1000d) label.Measure(size);
: string.Format(CultureInfo.InvariantCulture, "{0:F0} m", length);
line.Measure(size);
label.Measure(size);
}
return size;
} }
protected override void OnViewportChanged(ViewportChangedEventArgs e) return size;
{ }
InvalidateMeasure();
} protected override void OnViewportChanged(ViewportChangedEventArgs e)
{
InvalidateMeasure();
} }
} }

View file

@ -16,226 +16,225 @@ using Avalonia;
using Avalonia.Media; using Avalonia.Media;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Displays a Web Mercator tile pyramid.
/// </summary>
public partial class MapTileLayer : TilePyramidLayer
{ {
private const int TileSize = 256;
private static readonly Point MapTopLeft = new Point(-180d * MapProjection.Wgs84MeterPerDegree,
180d * MapProjection.Wgs84MeterPerDegree);
public static readonly DependencyProperty TileSourceProperty =
DependencyPropertyHelper.Register<MapTileLayer, TileSource>(nameof(TileSource), null,
(layer, oldValue, newValue) => layer.UpdateTileCollection(true));
public static readonly DependencyProperty MinZoomLevelProperty =
DependencyPropertyHelper.Register<MapTileLayer, int>(nameof(MinZoomLevel), 0);
public static readonly DependencyProperty MaxZoomLevelProperty =
DependencyPropertyHelper.Register<MapTileLayer, int>(nameof(MaxZoomLevel), 19);
public static readonly DependencyProperty ZoomLevelOffsetProperty =
DependencyPropertyHelper.Register<MapTileLayer, double>(nameof(ZoomLevelOffset), 0d);
/// <summary> /// <summary>
/// Displays a Web Mercator tile pyramid. /// A default MapTileLayer using OpenStreetMap data.
/// </summary> /// </summary>
public partial class MapTileLayer : TilePyramidLayer public static MapTileLayer OpenStreetMapTileLayer => new MapTileLayer
{ {
private const int TileSize = 256; TileSource = TileSource.Parse("https://tile.openstreetmap.org/{z}/{x}/{y}.png"),
SourceName = "OpenStreetMap",
Description = "© [OpenStreetMap Contributors](http://www.openstreetmap.org/copyright)"
};
private static readonly Point MapTopLeft = new Point(-180d * MapProjection.Wgs84MeterPerDegree, public MapTileLayer()
180d * MapProjection.Wgs84MeterPerDegree); {
this.SetRenderTransform(new MatrixTransform());
}
public static readonly DependencyProperty TileSourceProperty = public TileMatrix TileMatrix { get; private set; }
DependencyPropertyHelper.Register<MapTileLayer, TileSource>(nameof(TileSource), null,
(layer, oldValue, newValue) => layer.UpdateTileCollection(true));
public static readonly DependencyProperty MinZoomLevelProperty = public ICollection<ImageTile> Tiles { get; private set; } = [];
DependencyPropertyHelper.Register<MapTileLayer, int>(nameof(MinZoomLevel), 0);
public static readonly DependencyProperty MaxZoomLevelProperty = /// <summary>
DependencyPropertyHelper.Register<MapTileLayer, int>(nameof(MaxZoomLevel), 19); /// Provides the ImagesSource or image request Uri for map tiles.
/// </summary>
public TileSource TileSource
{
get => (TileSource)GetValue(TileSourceProperty);
set => SetValue(TileSourceProperty, value);
}
public static readonly DependencyProperty ZoomLevelOffsetProperty = /// <summary>
DependencyPropertyHelper.Register<MapTileLayer, double>(nameof(ZoomLevelOffset), 0d); /// Minimum zoom level supported by the MapTileLayer. Default value is 0.
/// </summary>
public int MinZoomLevel
{
get => (int)GetValue(MinZoomLevelProperty);
set => SetValue(MinZoomLevelProperty, value);
}
/// <summary> /// <summary>
/// A default MapTileLayer using OpenStreetMap data. /// Maximum zoom level supported by the MapTileLayer. Default value is 19.
/// </summary> /// </summary>
public static MapTileLayer OpenStreetMapTileLayer => new MapTileLayer public int MaxZoomLevel
{
get => (int)GetValue(MaxZoomLevelProperty);
set => SetValue(MaxZoomLevelProperty, value);
}
/// <summary>
/// Optional offset between the map zoom level and the topmost tile zoom level.
/// Default value is 0.
/// </summary>
public double ZoomLevelOffset
{
get => (double)GetValue(ZoomLevelOffsetProperty);
set => SetValue(ZoomLevelOffsetProperty, value);
}
protected override Size MeasureOverride(Size availableSize)
{
foreach (var tile in Tiles)
{ {
TileSource = TileSource.Parse("https://tile.openstreetmap.org/{z}/{x}/{y}.png"), tile.Image.Measure(availableSize);
SourceName = "OpenStreetMap",
Description = "© [OpenStreetMap Contributors](http://www.openstreetmap.org/copyright)"
};
public MapTileLayer()
{
this.SetRenderTransform(new MatrixTransform());
} }
public TileMatrix TileMatrix { get; private set; } return new Size();
}
public ICollection<ImageTile> Tiles { get; private set; } = []; protected override Size ArrangeOverride(Size finalSize)
{
/// <summary> foreach (var tile in Tiles)
/// Provides the ImagesSource or image request Uri for map tiles.
/// </summary>
public TileSource TileSource
{ {
get => (TileSource)GetValue(TileSourceProperty); // Arrange tiles relative to TileMatrix.XMin/YMin.
set => SetValue(TileSourceProperty, value);
}
/// <summary>
/// Minimum zoom level supported by the MapTileLayer. Default value is 0.
/// </summary>
public int MinZoomLevel
{
get => (int)GetValue(MinZoomLevelProperty);
set => SetValue(MinZoomLevelProperty, value);
}
/// <summary>
/// Maximum zoom level supported by the MapTileLayer. Default value is 19.
/// </summary>
public int MaxZoomLevel
{
get => (int)GetValue(MaxZoomLevelProperty);
set => SetValue(MaxZoomLevelProperty, value);
}
/// <summary>
/// Optional offset between the map zoom level and the topmost tile zoom level.
/// Default value is 0.
/// </summary>
public double ZoomLevelOffset
{
get => (double)GetValue(ZoomLevelOffsetProperty);
set => SetValue(ZoomLevelOffsetProperty, value);
}
protected override Size MeasureOverride(Size availableSize)
{
foreach (var tile in Tiles)
{
tile.Image.Measure(availableSize);
}
return new Size();
}
protected override Size ArrangeOverride(Size finalSize)
{
foreach (var tile in Tiles)
{
// Arrange tiles relative to TileMatrix.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 void UpdateRenderTransform()
{
if (TileMatrix != null)
{
// Tile matrix origin in pixels.
//
var tileMatrixOrigin = new Point(TileSize * TileMatrix.XMin, TileSize * TileMatrix.YMin);
var tileMatrixScale = MapBase.ZoomLevelToScale(TileMatrix.ZoomLevel);
((MatrixTransform)RenderTransform).Matrix =
ParentMap.ViewTransform.GetTileLayerTransform(tileMatrixScale, MapTopLeft, tileMatrixOrigin);
}
}
protected override void UpdateTileCollection()
{
UpdateTileCollection(false);
}
private void UpdateTileCollection(bool tileSourceChanged)
{
if (TileSource == null || ParentMap?.MapProjection.CrsId != WebMercatorProjection.DefaultCrsId)
{
CancelLoadTiles();
Children.Clear();
Tiles.Clear();
TileMatrix = null;
}
else if (SetTileMatrix() || tileSourceChanged)
{
if (tileSourceChanged)
{
Tiles.Clear();
}
UpdateRenderTransform();
UpdateTiles();
BeginLoadTiles(Tiles, TileSource, SourceName);
}
}
private bool SetTileMatrix()
{
// Add 0.001 to avoid floating point precision.
// //
var tileMatrixZoomLevel = (int)Math.Floor(ParentMap.ZoomLevel - ZoomLevelOffset + 0.001); var tileSize = TileSize << (TileMatrix.ZoomLevel - tile.ZoomLevel);
var tileMatrixScale = MapBase.ZoomLevelToScale(tileMatrixZoomLevel); var x = tileSize * tile.X - TileSize * TileMatrix.XMin;
var y = tileSize * tile.Y - TileSize * TileMatrix.YMin;
// Tile matrix bounds in pixels. tile.Image.Width = tileSize;
// tile.Image.Height = tileSize;
var bounds = ParentMap.ViewTransform.GetTileMatrixBounds(tileMatrixScale, MapTopLeft, ParentMap.ActualWidth, ParentMap.ActualHeight); tile.Image.Arrange(new Rect(x, y, tileSize, tileSize));
// 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() return finalSize;
}
protected override void UpdateRenderTransform()
{
if (TileMatrix != null)
{ {
var tiles = new ImageTileList(); // Tile matrix origin in pixels.
var maxZoomLevel = Math.Min(TileMatrix.ZoomLevel, MaxZoomLevel); //
var tileMatrixOrigin = new Point(TileSize * TileMatrix.XMin, TileSize * TileMatrix.YMin);
var tileMatrixScale = MapBase.ZoomLevelToScale(TileMatrix.ZoomLevel);
if (maxZoomLevel >= MinZoomLevel) ((MatrixTransform)RenderTransform).Matrix =
{ ParentMap.ViewTransform.GetTileLayerTransform(tileMatrixScale, MapTopLeft, tileMatrixOrigin);
var minZoomLevel = maxZoomLevel; }
}
if (IsBaseMapLayer) protected override void UpdateTileCollection()
{ {
var bgLevels = Math.Max(MaxBackgroundLevels, 0); UpdateTileCollection(false);
minZoomLevel = Math.Max(TileMatrix.ZoomLevel - bgLevels, MinZoomLevel); }
}
for (var zoomLevel = minZoomLevel; zoomLevel <= maxZoomLevel; zoomLevel++)
{
var tileCount = 1 << zoomLevel; // per row and column
// Right-shift divides with rounding down also negative values, https://stackoverflow.com/q/55196178
//
var shift = TileMatrix.ZoomLevel - zoomLevel;
var xMin = TileMatrix.XMin >> shift; // may be < 0
var xMax = TileMatrix.XMax >> shift; // may be >= tileCount
var yMin = Math.Max(TileMatrix.YMin >> shift, 0);
var yMax = Math.Min(TileMatrix.YMax >> shift, tileCount - 1);
tiles.FillMatrix(Tiles, zoomLevel, xMin, yMin, xMax, yMax, tileCount);
}
}
Tiles = tiles;
private void UpdateTileCollection(bool tileSourceChanged)
{
if (TileSource == null || ParentMap?.MapProjection.CrsId != WebMercatorProjection.DefaultCrsId)
{
CancelLoadTiles();
Children.Clear(); Children.Clear();
Tiles.Clear();
foreach (var tile in tiles) TileMatrix = null;
}
else if (SetTileMatrix() || tileSourceChanged)
{
if (tileSourceChanged)
{ {
Children.Add(tile.Image); Tiles.Clear();
} }
UpdateRenderTransform();
UpdateTiles();
BeginLoadTiles(Tiles, TileSource, SourceName);
}
}
private bool SetTileMatrix()
{
// Add 0.001 to avoid floating point precision.
//
var tileMatrixZoomLevel = (int)Math.Floor(ParentMap.ZoomLevel - ZoomLevelOffset + 0.001);
var tileMatrixScale = MapBase.ZoomLevelToScale(tileMatrixZoomLevel);
// Tile matrix bounds in pixels.
//
var bounds = ParentMap.ViewTransform.GetTileMatrixBounds(tileMatrixScale, MapTopLeft, ParentMap.ActualWidth, ParentMap.ActualHeight);
// 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 ImageTileList();
var maxZoomLevel = Math.Min(TileMatrix.ZoomLevel, MaxZoomLevel);
if (maxZoomLevel >= MinZoomLevel)
{
var minZoomLevel = maxZoomLevel;
if (IsBaseMapLayer)
{
var bgLevels = Math.Max(MaxBackgroundLevels, 0);
minZoomLevel = Math.Max(TileMatrix.ZoomLevel - bgLevels, MinZoomLevel);
}
for (var zoomLevel = minZoomLevel; zoomLevel <= maxZoomLevel; zoomLevel++)
{
var tileCount = 1 << zoomLevel; // per row and column
// Right-shift divides with rounding down also negative values, https://stackoverflow.com/q/55196178
//
var shift = TileMatrix.ZoomLevel - zoomLevel;
var xMin = TileMatrix.XMin >> shift; // may be < 0
var xMax = TileMatrix.XMax >> shift; // may be >= tileCount
var yMin = Math.Max(TileMatrix.YMin >> shift, 0);
var yMax = Math.Min(TileMatrix.YMax >> shift, tileCount - 1);
tiles.FillMatrix(Tiles, zoomLevel, xMin, yMin, xMax, yMax, tileCount);
}
}
Tiles = tiles;
Children.Clear();
foreach (var tile in tiles)
{
Children.Add(tile.Image);
} }
} }
} }

View file

@ -8,112 +8,111 @@ using Avalonia;
using FrameworkMatrix = Avalonia.Matrix; using FrameworkMatrix = Avalonia.Matrix;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Replaces Windows.UI.Xaml.Media.Matrix, Microsoft.UI.Xaml.Media.Matrix and Avalonia.Matrix
/// to expose Translate, Rotate and Invert methods.
/// </summary>
public struct Matrix(double m11, double m12, double m21, double m22, double offsetX, double offsetY)
{ {
/// <summary> public double M11 { get; private set; } = m11;
/// Replaces Windows.UI.Xaml.Media.Matrix, Microsoft.UI.Xaml.Media.Matrix and Avalonia.Matrix public double M12 { get; private set; } = m12;
/// to expose Translate, Rotate and Invert methods. public double M21 { get; private set; } = m21;
/// </summary> public double M22 { get; private set; } = m22;
public struct Matrix(double m11, double m12, double m21, double m22, double offsetX, double offsetY) public double OffsetX { get; private set; } = offsetX;
public double OffsetY { get; private set; } = offsetY;
public static implicit operator Matrix(FrameworkMatrix m)
{ {
public double M11 { get; private set; } = m11;
public double M12 { get; private set; } = m12;
public double M21 { get; private set; } = m21;
public double M22 { get; private set; } = m22;
public double OffsetX { get; private set; } = offsetX;
public double OffsetY { get; private set; } = offsetY;
public static implicit operator Matrix(FrameworkMatrix m)
{
#if AVALONIA #if AVALONIA
return new Matrix(m.M11, m.M12, m.M21, m.M22, m.M31, m.M32); return new Matrix(m.M11, m.M12, m.M21, m.M22, m.M31, m.M32);
#else #else
return new Matrix(m.M11, m.M12, m.M21, m.M22, m.OffsetX, m.OffsetY); return new Matrix(m.M11, m.M12, m.M21, m.M22, m.OffsetX, m.OffsetY);
#endif #endif
} }
public static implicit operator FrameworkMatrix(Matrix m) public static implicit operator FrameworkMatrix(Matrix m)
{
return new FrameworkMatrix(m.M11, m.M12, m.M21, m.M22, m.OffsetX, m.OffsetY);
}
public readonly Point Transform(Point p)
{
return new Point(
M11 * p.X + M21 * p.Y + OffsetX,
M12 * p.X + M22 * p.Y + OffsetY);
}
public void Translate(double x, double y)
{
OffsetX += x;
OffsetY += y;
}
public void Scale(double scaleX, double scaleY)
{
SetMatrix(
M11 * scaleX,
M12 * scaleY,
M21 * scaleX,
M22 * scaleY,
OffsetX * scaleX,
OffsetY * scaleY);
}
public void Rotate(double angle)
{
angle = angle % 360d * Math.PI / 180d;
if (angle != 0d)
{ {
return new FrameworkMatrix(m.M11, m.M12, m.M21, m.M22, m.OffsetX, m.OffsetY); var cos = Math.Cos(angle);
} var sin = Math.Sin(angle);
public readonly Point Transform(Point p)
{
return new Point(
M11 * p.X + M21 * p.Y + OffsetX,
M12 * p.X + M22 * p.Y + OffsetY);
}
public void Translate(double x, double y)
{
OffsetX += x;
OffsetY += y;
}
public void Scale(double scaleX, double scaleY)
{
SetMatrix(
M11 * scaleX,
M12 * scaleY,
M21 * scaleX,
M22 * scaleY,
OffsetX * scaleX,
OffsetY * scaleY);
}
public void Rotate(double angle)
{
angle = angle % 360d * Math.PI / 180d;
if (angle != 0d)
{
var cos = Math.Cos(angle);
var sin = Math.Sin(angle);
SetMatrix(
M11 * cos - M12 * sin,
M11 * sin + M12 * cos,
M21 * cos - M22 * sin,
M21 * sin + M22 * cos,
OffsetX * cos - OffsetY * sin,
OffsetX * sin + OffsetY * cos);
}
}
public void Invert()
{
var invDet = 1d / (M11 * M22 - M12 * M21);
if (double.IsInfinity(invDet))
{
throw new InvalidOperationException("Matrix is not invertible.");
}
SetMatrix( SetMatrix(
invDet * M22, invDet * -M12, invDet * -M21, invDet * M11, M11 * cos - M12 * sin,
invDet * (M21 * OffsetY - M22 * OffsetX), M11 * sin + M12 * cos,
invDet * (M12 * OffsetX - M11 * OffsetY)); M21 * cos - M22 * sin,
} M21 * sin + M22 * cos,
OffsetX * cos - OffsetY * sin,
public static Matrix Multiply(Matrix m1, Matrix m2) OffsetX * sin + OffsetY * cos);
{
return new Matrix(
m1.M11 * m2.M11 + m1.M12 * m2.M21,
m1.M11 * m2.M12 + m1.M12 * m2.M22,
m1.M21 * m2.M11 + m1.M22 * m2.M21,
m1.M21 * m2.M12 + m1.M22 * m2.M22,
m1.OffsetX * m2.M11 + m1.OffsetY * m2.M21 + m2.OffsetX,
m1.OffsetX * m2.M12 + m1.OffsetY * m2.M22 + m2.OffsetY);
}
private void SetMatrix(double m11, double m12, double m21, double m22, double offsetX, double offsetY)
{
M11 = m11;
M12 = m12;
M21 = m21;
M22 = m22;
OffsetX = offsetX;
OffsetY = offsetY;
} }
} }
public void Invert()
{
var invDet = 1d / (M11 * M22 - M12 * M21);
if (double.IsInfinity(invDet))
{
throw new InvalidOperationException("Matrix is not invertible.");
}
SetMatrix(
invDet * M22, invDet * -M12, invDet * -M21, invDet * M11,
invDet * (M21 * OffsetY - M22 * OffsetX),
invDet * (M12 * OffsetX - M11 * OffsetY));
}
public static Matrix Multiply(Matrix m1, Matrix m2)
{
return new Matrix(
m1.M11 * m2.M11 + m1.M12 * m2.M21,
m1.M11 * m2.M12 + m1.M12 * m2.M22,
m1.M21 * m2.M11 + m1.M22 * m2.M21,
m1.M21 * m2.M12 + m1.M22 * m2.M22,
m1.OffsetX * m2.M11 + m1.OffsetY * m2.M21 + m2.OffsetX,
m1.OffsetX * m2.M12 + m1.OffsetY * m2.M22 + m2.OffsetY);
}
private void SetMatrix(double m11, double m12, double m21, double m22, double offsetX, double offsetY)
{
M11 = m11;
M12 = m12;
M21 = m21;
M22 = m22;
OffsetX = offsetX;
OffsetY = offsetY;
}
} }

View file

@ -15,53 +15,52 @@ using Avalonia.Layout;
using PathFigureCollection = Avalonia.Media.PathFigures; using PathFigureCollection = Avalonia.Media.PathFigures;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Draws a metric grid overlay.
/// </summary>
public partial class MetricGrid : MapGrid
{ {
/// <summary> protected override void DrawGrid(PathFigureCollection figures, List<Label> labels)
/// Draws a metric grid overlay.
/// </summary>
public partial class MetricGrid : MapGrid
{ {
protected override void DrawGrid(PathFigureCollection figures, List<Label> labels) var minLineDistance = Math.Max(MinLineDistance / ParentMap.ViewTransform.Scale, 1d);
var lineDistance = Math.Pow(10d, Math.Ceiling(Math.Log10(minLineDistance)));
if (lineDistance * 0.5 >= minLineDistance)
{ {
var minLineDistance = Math.Max(MinLineDistance / ParentMap.ViewTransform.Scale, 1d); lineDistance *= 0.5;
var lineDistance = Math.Pow(10d, Math.Ceiling(Math.Log10(minLineDistance)));
if (lineDistance * 0.5 >= minLineDistance) if (lineDistance * 0.4 >= minLineDistance)
{ {
lineDistance *= 0.5; lineDistance *= 0.4;
if (lineDistance * 0.4 >= minLineDistance)
{
lineDistance *= 0.4;
}
} }
}
var mapRect = ParentMap.ViewTransform.ViewToMapBounds(new Rect(0d, 0d, ParentMap.ActualWidth, ParentMap.ActualHeight)); var mapRect = ParentMap.ViewTransform.ViewToMapBounds(new Rect(0d, 0d, ParentMap.ActualWidth, ParentMap.ActualHeight));
var minX = Math.Ceiling(mapRect.X / lineDistance) * lineDistance; var minX = Math.Ceiling(mapRect.X / lineDistance) * lineDistance;
var minY = Math.Ceiling(mapRect.Y / lineDistance) * lineDistance; var minY = Math.Ceiling(mapRect.Y / lineDistance) * lineDistance;
for (var x = minX; x <= mapRect.X + mapRect.Width; x += lineDistance) for (var x = minX; x <= mapRect.X + mapRect.Width; x += lineDistance)
{ {
var p1 = ParentMap.ViewTransform.MapToView(new Point(x, mapRect.Y)); var p1 = ParentMap.ViewTransform.MapToView(new Point(x, mapRect.Y));
var p2 = ParentMap.ViewTransform.MapToView(new Point(x, mapRect.Y + mapRect.Height)); var p2 = ParentMap.ViewTransform.MapToView(new Point(x, mapRect.Y + mapRect.Height));
figures.Add(CreateLineFigure(p1, p2)); figures.Add(CreateLineFigure(p1, p2));
var text = x.ToString("F0"); var text = x.ToString("F0");
labels.Add(new Label(text, p1.X, p1.Y, 0d, HorizontalAlignment.Left, VerticalAlignment.Bottom)); labels.Add(new Label(text, p1.X, p1.Y, 0d, HorizontalAlignment.Left, VerticalAlignment.Bottom));
labels.Add(new Label(text, p2.X, p2.Y, 0d, HorizontalAlignment.Left, VerticalAlignment.Top)); labels.Add(new Label(text, p2.X, p2.Y, 0d, HorizontalAlignment.Left, VerticalAlignment.Top));
} }
for (var y = minY; y <= mapRect.Y + mapRect.Height; y += lineDistance) for (var y = minY; y <= mapRect.Y + mapRect.Height; y += lineDistance)
{ {
var p1 = ParentMap.ViewTransform.MapToView(new Point(mapRect.X, y)); var p1 = ParentMap.ViewTransform.MapToView(new Point(mapRect.X, y));
var p2 = ParentMap.ViewTransform.MapToView(new Point(mapRect.X + mapRect.Width, y)); var p2 = ParentMap.ViewTransform.MapToView(new Point(mapRect.X + mapRect.Width, y));
figures.Add(CreateLineFigure(p1, p2)); figures.Add(CreateLineFigure(p1, p2));
var text = y.ToString("F0"); var text = y.ToString("F0");
labels.Add(new Label(text, p1.X, p1.Y, 0d, HorizontalAlignment.Left, VerticalAlignment.Bottom)); labels.Add(new Label(text, p1.X, p1.Y, 0d, HorizontalAlignment.Left, VerticalAlignment.Bottom));
labels.Add(new Label(text, p2.X, p2.Y, 0d, HorizontalAlignment.Right, VerticalAlignment.Bottom)); labels.Add(new Label(text, p2.X, p2.Y, 0d, HorizontalAlignment.Right, VerticalAlignment.Bottom));
}
} }
} }
} }

View file

@ -6,120 +6,119 @@ using System.Windows.Media;
using Avalonia; using Avalonia;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Elliptical Polar Stereographic Projection with scale factor at the pole and
/// false easting and northing, as used by the UPS North and UPS South projections.
/// See "Map Projections - A Working Manual" (https://pubs.usgs.gov/publication/pp1395), p.154-163.
/// </summary>
public class PolarStereographicProjection : MapProjection
{ {
/// <summary> public override double GridConvergence(double latitude, double longitude)
/// Elliptical Polar Stereographic Projection with scale factor at the pole and
/// false easting and northing, as used by the UPS North and UPS South projections.
/// See "Map Projections - A Working Manual" (https://pubs.usgs.gov/publication/pp1395), p.154-163.
/// </summary>
public class PolarStereographicProjection : MapProjection
{ {
public override double GridConvergence(double latitude, double longitude) return Math.Sign(LatitudeOfOrigin) * (longitude - CentralMeridian);
{
return Math.Sign(LatitudeOfOrigin) * (longitude - CentralMeridian);
}
public override Matrix RelativeTransform(double latitude, double longitude)
{
var sign = Math.Sign(LatitudeOfOrigin);
var phi = sign * latitude * Math.PI / 180d;
var e = Math.Sqrt((2d - Flattening) * Flattening);
var eSinPhi = e * Math.Sin(phi);
var t = Math.Tan(Math.PI / 4d - phi / 2d)
/ Math.Pow((1d - eSinPhi) / (1d + eSinPhi), e / 2d); // p.161 (15-9)
// r == ρ/a
var r = 2d * ScaleFactor * t / Math.Sqrt(Math.Pow(1d + e, 1d + e) * Math.Pow(1d - e, 1d - e)); // p.161 (21-33)
var m = Math.Cos(phi) / Math.Sqrt(1d - eSinPhi * eSinPhi); // p.160 (14-15)
var k = r / m; // p.161 (21-32)
var transform = new Matrix(k, 0d, 0d, k, 0d, 0d);
transform.Rotate(-sign * (longitude - CentralMeridian));
return transform;
}
public override Point LocationToMap(double latitude, double longitude)
{
var sign = Math.Sign(LatitudeOfOrigin);
var phi = sign * latitude * Math.PI / 180d;
var lambda = sign * longitude * Math.PI / 180d;
var e = Math.Sqrt((2d - Flattening) * Flattening);
var eSinPhi = e * Math.Sin(phi);
var t = Math.Tan(Math.PI / 4d - phi / 2d)
/ Math.Pow((1d - eSinPhi) / (1d + eSinPhi), e / 2d); // p.161 (15-9)
// ρ
var r = 2d * EquatorialRadius * ScaleFactor * t
/ Math.Sqrt(Math.Pow(1d + e, 1d + e) * Math.Pow(1d - e, 1d - e)); // p.161 (21-33)
var x = sign * r * Math.Sin(lambda); // p.161 (21-30)
var y = sign * -r * Math.Cos(lambda); // p.161 (21-31)
return new Point(x + FalseEasting, y + FalseNorthing);
}
public override Location MapToLocation(double x, double y)
{
var sign = Math.Sign(LatitudeOfOrigin);
x = sign * (x - FalseEasting);
y = sign * (y - FalseNorthing);
var e2 = (2d - Flattening) * Flattening;
var e = Math.Sqrt(e2);
var r = Math.Sqrt(x * x + y * y); // p.162 (20-18)
var t = r * Math.Sqrt(Math.Pow(1d + e, 1d + e) * Math.Pow(1d - e, 1d - e))
/ (2d * EquatorialRadius * ScaleFactor); // p.162 (21-39)
var phi = WorldMercatorProjection.ApproximateLatitude(e2, t); // p.162 (3-5)
var lambda = Math.Atan2(x, -y); // p.162 (20-16)
return new Location(sign * phi * 180d / Math.PI, sign * lambda * 180d / Math.PI);
}
} }
/// <summary> public override Matrix RelativeTransform(double latitude, double longitude)
/// Universal Polar Stereographic North Projection - EPSG:32661.
/// </summary>
public class Wgs84UpsNorthProjection : PolarStereographicProjection
{ {
public const string DefaultCrsId = "EPSG:32661"; var sign = Math.Sign(LatitudeOfOrigin);
var phi = sign * latitude * Math.PI / 180d;
public Wgs84UpsNorthProjection() // parameterless constructor for XAML var e = Math.Sqrt((2d - Flattening) * Flattening);
: this(DefaultCrsId) var eSinPhi = e * Math.Sin(phi);
{ var t = Math.Tan(Math.PI / 4d - phi / 2d)
} / Math.Pow((1d - eSinPhi) / (1d + eSinPhi), e / 2d); // p.161 (15-9)
// r == ρ/a
var r = 2d * ScaleFactor * t / Math.Sqrt(Math.Pow(1d + e, 1d + e) * Math.Pow(1d - e, 1d - e)); // p.161 (21-33)
var m = Math.Cos(phi) / Math.Sqrt(1d - eSinPhi * eSinPhi); // p.160 (14-15)
var k = r / m; // p.161 (21-32)
public Wgs84UpsNorthProjection(string crsId) var transform = new Matrix(k, 0d, 0d, k, 0d, 0d);
{ transform.Rotate(-sign * (longitude - CentralMeridian));
CrsId = crsId;
ScaleFactor = 0.994; return transform;
LatitudeOfOrigin = 90d;
FalseEasting = 2e6;
FalseNorthing = 2e6;
}
} }
/// <summary> public override Point LocationToMap(double latitude, double longitude)
/// Universal Polar Stereographic South Projection - EPSG:32761.
/// </summary>
public class Wgs84UpsSouthProjection : PolarStereographicProjection
{ {
public const string DefaultCrsId = "EPSG:32761"; var sign = Math.Sign(LatitudeOfOrigin);
var phi = sign * latitude * Math.PI / 180d;
var lambda = sign * longitude * Math.PI / 180d;
public Wgs84UpsSouthProjection() // parameterless constructor for XAML var e = Math.Sqrt((2d - Flattening) * Flattening);
: this(DefaultCrsId) var eSinPhi = e * Math.Sin(phi);
{ var t = Math.Tan(Math.PI / 4d - phi / 2d)
} / Math.Pow((1d - eSinPhi) / (1d + eSinPhi), e / 2d); // p.161 (15-9)
// ρ
var r = 2d * EquatorialRadius * ScaleFactor * t
/ Math.Sqrt(Math.Pow(1d + e, 1d + e) * Math.Pow(1d - e, 1d - e)); // p.161 (21-33)
public Wgs84UpsSouthProjection(string crsId) var x = sign * r * Math.Sin(lambda); // p.161 (21-30)
{ var y = sign * -r * Math.Cos(lambda); // p.161 (21-31)
CrsId = crsId;
ScaleFactor = 0.994; return new Point(x + FalseEasting, y + FalseNorthing);
LatitudeOfOrigin = -90d; }
FalseEasting = 2e6;
FalseNorthing = 2e6; public override Location MapToLocation(double x, double y)
} {
var sign = Math.Sign(LatitudeOfOrigin);
x = sign * (x - FalseEasting);
y = sign * (y - FalseNorthing);
var e2 = (2d - Flattening) * Flattening;
var e = Math.Sqrt(e2);
var r = Math.Sqrt(x * x + y * y); // p.162 (20-18)
var t = r * Math.Sqrt(Math.Pow(1d + e, 1d + e) * Math.Pow(1d - e, 1d - e))
/ (2d * EquatorialRadius * ScaleFactor); // p.162 (21-39)
var phi = WorldMercatorProjection.ApproximateLatitude(e2, t); // p.162 (3-5)
var lambda = Math.Atan2(x, -y); // p.162 (20-16)
return new Location(sign * phi * 180d / Math.PI, sign * lambda * 180d / Math.PI);
}
}
/// <summary>
/// Universal Polar Stereographic North Projection - EPSG:32661.
/// </summary>
public class Wgs84UpsNorthProjection : PolarStereographicProjection
{
public const string DefaultCrsId = "EPSG:32661";
public Wgs84UpsNorthProjection() // parameterless constructor for XAML
: this(DefaultCrsId)
{
}
public Wgs84UpsNorthProjection(string crsId)
{
CrsId = crsId;
ScaleFactor = 0.994;
LatitudeOfOrigin = 90d;
FalseEasting = 2e6;
FalseNorthing = 2e6;
}
}
/// <summary>
/// Universal Polar Stereographic South Projection - EPSG:32761.
/// </summary>
public class Wgs84UpsSouthProjection : PolarStereographicProjection
{
public const string DefaultCrsId = "EPSG:32761";
public Wgs84UpsSouthProjection() // parameterless constructor for XAML
: this(DefaultCrsId)
{
}
public Wgs84UpsSouthProjection(string crsId)
{
CrsId = crsId;
ScaleFactor = 0.994;
LatitudeOfOrigin = -90d;
FalseEasting = 2e6;
FalseNorthing = 2e6;
} }
} }

View file

@ -3,63 +3,62 @@ using System.Collections.ObjectModel;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Linq; using System.Linq;
namespace MapControl namespace MapControl;
/// <summary>
/// An ObservableCollection of IEnumerable of Location. PolygonCollection adds a CollectionChanged
/// listener to each element that implements INotifyCollectionChanged and, when such an element changes,
/// fires its own CollectionChanged event with NotifyCollectionChangedAction.Replace for that element.
/// </summary>
public partial class PolygonCollection : ObservableCollection<IEnumerable<Location>>
{ {
/// <summary> private void PolygonChanged(object sender, NotifyCollectionChangedEventArgs e)
/// An ObservableCollection of IEnumerable of Location. PolygonCollection adds a CollectionChanged
/// listener to each element that implements INotifyCollectionChanged and, when such an element changes,
/// fires its own CollectionChanged event with NotifyCollectionChangedAction.Replace for that element.
/// </summary>
public partial class PolygonCollection : ObservableCollection<IEnumerable<Location>>
{ {
private void PolygonChanged(object sender, NotifyCollectionChangedEventArgs e) OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, sender, sender));
}
protected override void InsertItem(int index, IEnumerable<Location> polygon)
{
if (polygon is INotifyCollectionChanged addedPolygon)
{ {
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, sender, sender)); addedPolygon.CollectionChanged += PolygonChanged;
} }
protected override void InsertItem(int index, IEnumerable<Location> polygon) base.InsertItem(index, polygon);
{ }
if (polygon is INotifyCollectionChanged addedPolygon)
{
addedPolygon.CollectionChanged += PolygonChanged;
}
base.InsertItem(index, polygon); protected override void SetItem(int index, IEnumerable<Location> polygon)
{
if (this[index] is INotifyCollectionChanged removedPolygon)
{
removedPolygon.CollectionChanged -= PolygonChanged;
} }
protected override void SetItem(int index, IEnumerable<Location> polygon) if (polygon is INotifyCollectionChanged addedPolygon)
{ {
if (this[index] is INotifyCollectionChanged removedPolygon) addedPolygon.CollectionChanged += PolygonChanged;
{
removedPolygon.CollectionChanged -= PolygonChanged;
}
if (polygon is INotifyCollectionChanged addedPolygon)
{
addedPolygon.CollectionChanged += PolygonChanged;
}
base.SetItem(index, polygon);
} }
protected override void RemoveItem(int index) base.SetItem(index, polygon);
{ }
if (this[index] is INotifyCollectionChanged removedPolygon)
{
removedPolygon.CollectionChanged -= PolygonChanged;
}
base.RemoveItem(index); protected override void RemoveItem(int index)
{
if (this[index] is INotifyCollectionChanged removedPolygon)
{
removedPolygon.CollectionChanged -= PolygonChanged;
} }
protected override void ClearItems() base.RemoveItem(index);
{ }
foreach (var polygon in this.OfType<INotifyCollectionChanged>())
{
polygon.CollectionChanged -= PolygonChanged;
}
base.ClearItems(); protected override void ClearItems()
{
foreach (var polygon in this.OfType<INotifyCollectionChanged>())
{
polygon.CollectionChanged -= PolygonChanged;
} }
base.ClearItems();
} }
} }

View file

@ -16,106 +16,105 @@ using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
#endif #endif
namespace MapControl namespace MapControl;
public partial class PushpinBorder
{ {
public partial class PushpinBorder public Size ArrowSize
{ {
public Size ArrowSize get => (Size)GetValue(ArrowSizeProperty);
{ set => SetValue(ArrowSizeProperty, value);
get => (Size)GetValue(ArrowSizeProperty);
set => SetValue(ArrowSizeProperty, value);
}
public double BorderWidth
{
get => (double)GetValue(BorderWidthProperty);
set => SetValue(BorderWidthProperty, value);
}
protected virtual Geometry BuildGeometry()
{
var width = Math.Floor(ActualWidth);
var height = Math.Floor(ActualHeight);
var x1 = BorderWidth / 2d;
var y1 = BorderWidth / 2d;
var x2 = width - x1;
var y3 = height - y1;
var y2 = y3 - ArrowSize.Height;
var aw = ArrowSize.Width;
var r1 = CornerRadius.TopLeft;
var r2 = CornerRadius.TopRight;
var r3 = CornerRadius.BottomRight;
var r4 = CornerRadius.BottomLeft;
var figure = new PathFigure
{
StartPoint = new Point(x1, y1 + r1),
IsClosed = true,
IsFilled = true
};
figure.ArcTo(x1 + r1, y1, r1);
figure.LineTo(x2 - r2, y1);
figure.ArcTo(x2, y1 + r2, r2);
if (HorizontalAlignment == HorizontalAlignment.Right)
{
figure.LineTo(x2, y3);
figure.LineTo(x2 - aw, y2);
}
else
{
figure.LineTo(x2, y2 - r3);
figure.ArcTo(x2 - r3, y2, r3);
}
if (HorizontalAlignment == HorizontalAlignment.Center)
{
var c = width / 2d;
figure.LineTo(c + aw / 2d, y2);
figure.LineTo(c, y3);
figure.LineTo(c - aw / 2d, y2);
}
if (HorizontalAlignment == HorizontalAlignment.Left || HorizontalAlignment == HorizontalAlignment.Stretch)
{
figure.LineTo(x1 + aw, y2);
figure.LineTo(x1, y3);
}
else
{
figure.LineTo(x1 + r4, y2);
figure.ArcTo(x1, y2 - r4, r4);
}
var geometry = new PathGeometry();
geometry.Figures.Add(figure);
return geometry;
}
} }
internal static class PathFigureExtensions public double BorderWidth
{ {
public static void LineTo(this PathFigure figure, double x, double y) get => (double)GetValue(BorderWidthProperty);
set => SetValue(BorderWidthProperty, value);
}
protected virtual Geometry BuildGeometry()
{
var width = Math.Floor(ActualWidth);
var height = Math.Floor(ActualHeight);
var x1 = BorderWidth / 2d;
var y1 = BorderWidth / 2d;
var x2 = width - x1;
var y3 = height - y1;
var y2 = y3 - ArrowSize.Height;
var aw = ArrowSize.Width;
var r1 = CornerRadius.TopLeft;
var r2 = CornerRadius.TopRight;
var r3 = CornerRadius.BottomRight;
var r4 = CornerRadius.BottomLeft;
var figure = new PathFigure
{ {
figure.Segments.Add(new LineSegment StartPoint = new Point(x1, y1 + r1),
{ IsClosed = true,
Point = new Point(x, y) IsFilled = true
}); };
figure.ArcTo(x1 + r1, y1, r1);
figure.LineTo(x2 - r2, y1);
figure.ArcTo(x2, y1 + r2, r2);
if (HorizontalAlignment == HorizontalAlignment.Right)
{
figure.LineTo(x2, y3);
figure.LineTo(x2 - aw, y2);
}
else
{
figure.LineTo(x2, y2 - r3);
figure.ArcTo(x2 - r3, y2, r3);
} }
public static void ArcTo(this PathFigure figure, double x, double y, double r) if (HorizontalAlignment == HorizontalAlignment.Center)
{ {
if (r > 0d) var c = width / 2d;
figure.LineTo(c + aw / 2d, y2);
figure.LineTo(c, y3);
figure.LineTo(c - aw / 2d, y2);
}
if (HorizontalAlignment == HorizontalAlignment.Left || HorizontalAlignment == HorizontalAlignment.Stretch)
{
figure.LineTo(x1 + aw, y2);
figure.LineTo(x1, y3);
}
else
{
figure.LineTo(x1 + r4, y2);
figure.ArcTo(x1, y2 - r4, r4);
}
var geometry = new PathGeometry();
geometry.Figures.Add(figure);
return geometry;
}
}
internal static class PathFigureExtensions
{
public static void LineTo(this PathFigure figure, double x, double y)
{
figure.Segments.Add(new LineSegment
{
Point = new Point(x, y)
});
}
public static void ArcTo(this PathFigure figure, double x, double y, double r)
{
if (r > 0d)
{
figure.Segments.Add(new ArcSegment
{ {
figure.Segments.Add(new ArcSegment Point = new Point(x, y),
{ Size = new Size(r, r),
Point = new Point(x, y), SweepDirection = SweepDirection.Clockwise
Size = new Size(r, r), });
SweepDirection = SweepDirection.Clockwise
});
}
} }
} }
} }

View file

@ -7,136 +7,135 @@ using System.Windows.Media;
using Avalonia; using Avalonia;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Spherical Stereographic Projection - AUTO2:97002.
/// See "Map Projections - A Working Manual" (https://pubs.usgs.gov/publication/pp1395), p.157-160.
/// </summary>
public class StereographicProjection : MapProjection
{ {
/// <summary> public const string DefaultCrsId = "AUTO2:97002"; // GeoServer non-standard CRS identifier
/// Spherical Stereographic Projection - AUTO2:97002.
/// See "Map Projections - A Working Manual" (https://pubs.usgs.gov/publication/pp1395), p.157-160. public StereographicProjection(string crsId)
/// </summary>
public class StereographicProjection : MapProjection
{ {
public const string DefaultCrsId = "AUTO2:97002"; // GeoServer non-standard CRS identifier var parameters = crsId.Split(',');
public StereographicProjection(string crsId) if (parameters.Length != 4 ||
string.IsNullOrEmpty(parameters[0]) ||
!double.TryParse(parameters[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double scaleFactor) ||
!double.TryParse(parameters[2], NumberStyles.Float, CultureInfo.InvariantCulture, out double longitude) ||
!double.TryParse(parameters[3], NumberStyles.Float, CultureInfo.InvariantCulture, out double latitude))
{ {
var parameters = crsId.Split(','); throw new ArgumentException($"Invalid CRS Identifier {crsId}.", nameof(crsId));
if (parameters.Length != 4 ||
string.IsNullOrEmpty(parameters[0]) ||
!double.TryParse(parameters[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double scaleFactor) ||
!double.TryParse(parameters[2], NumberStyles.Float, CultureInfo.InvariantCulture, out double longitude) ||
!double.TryParse(parameters[3], NumberStyles.Float, CultureInfo.InvariantCulture, out double latitude))
{
throw new ArgumentException($"Invalid CRS Identifier {crsId}.", nameof(crsId));
}
CrsId = crsId;
ScaleFactor = scaleFactor;
CentralMeridian = longitude;
LatitudeOfOrigin = latitude;
} }
public StereographicProjection(double centerLatitude, double centerLongitude, double scaleFactor = 1d, string crsId = DefaultCrsId) CrsId = crsId;
ScaleFactor = scaleFactor;
CentralMeridian = longitude;
LatitudeOfOrigin = latitude;
}
public StereographicProjection(double centerLatitude, double centerLongitude, double scaleFactor = 1d, string crsId = DefaultCrsId)
{
CrsId = string.Format(CultureInfo.InvariantCulture,
"{0},{1:0.########},{2:0.########},{3:0.########}", crsId, scaleFactor, centerLongitude, centerLatitude);
ScaleFactor = scaleFactor;
CentralMeridian = centerLongitude;
LatitudeOfOrigin = centerLatitude;
}
private void GetScaleAndGridConvergence(double latitude, double longitude, out double scale, out double gamma)
{
var phi0 = LatitudeOfOrigin * Math.PI / 180d; // φ1
var phi1 = latitude * Math.PI / 180d;
var phi2 = (latitude + 1e-3) * Math.PI / 180d;
var lambda = (longitude - CentralMeridian) * Math.PI / 180d; // λ - λ0
var sinPhi0 = Math.Sin(phi0);
var cosPhi0 = Math.Cos(phi0);
var sinPhi1 = Math.Sin(phi1);
var cosPhi1 = Math.Cos(phi1);
var sinPhi2 = Math.Sin(phi2);
var cosPhi2 = Math.Cos(phi2);
var sinLambda = Math.Sin(lambda);
var cosLambda = Math.Cos(lambda);
var k1 = 2d / (1d + sinPhi0 * sinPhi1 + cosPhi0 * cosPhi1 * cosLambda);
var k2 = 2d / (1d + sinPhi0 * sinPhi2 + cosPhi0 * cosPhi2 * cosLambda);
var c = k2 * cosPhi2 - k1 * cosPhi1;
var s = k2 * sinPhi2 - k1 * sinPhi1;
scale = k1;
gamma = Math.Atan2(-sinLambda * c, cosPhi0 * s - sinPhi0 * cosLambda * c) * 180d / Math.PI;
}
public override double GridConvergence(double latitude, double longitude)
{
GetScaleAndGridConvergence(latitude, longitude, out double _, out double gamma);
return gamma;
}
public override Matrix RelativeTransform(double latitude, double longitude)
{
GetScaleAndGridConvergence(latitude, longitude, out double scale, out double gamma);
var transform = new Matrix(scale, 0d, 0d, scale, 0d, 0d);
transform.Rotate(-gamma);
return transform;
}
public override Point LocationToMap(double latitude, double longitude)
{
var phi0 = LatitudeOfOrigin * Math.PI / 180d; // φ1
var phi = latitude * Math.PI / 180d; // φ
var lambda = (longitude - CentralMeridian) * Math.PI / 180d; // λ - λ0
var sinPhi0 = Math.Sin(phi0);
var cosPhi0 = Math.Cos(phi0);
var sinPhi = Math.Sin(phi);
var cosPhi = Math.Cos(phi);
var sinLambda = Math.Sin(lambda);
var cosPhiCosLambda = cosPhi * Math.Cos(lambda);
var x = cosPhi * sinLambda;
var y = cosPhi0 * sinPhi - sinPhi0 * cosPhiCosLambda;
var k = 2d / (1d + sinPhi0 * sinPhi + cosPhi0 * cosPhiCosLambda); // p.157 (21-4), k0 == 1
return new Point(
EquatorialRadius * k * x,
EquatorialRadius * k * y); // p.157 (21-2/3)
}
public override Location MapToLocation(double x, double y)
{
var rho = Math.Sqrt(x * x + y * y);
var c = 2d * Math.Atan(rho / (2d * EquatorialRadius)); // p.159 (21-15), k0 == 1
var cosC = Math.Cos(c);
var sinC = Math.Sin(c);
var phi0 = LatitudeOfOrigin * Math.PI / 180d; // φ1
var cosPhi0 = Math.Cos(phi0);
var sinPhi0 = Math.Sin(phi0);
var phi = Math.Asin(cosC * sinPhi0 + y * sinC * cosPhi0 / rho); // (20-14)
double u, v;
if (LatitudeOfOrigin == 90d) // (20-16)
{ {
CrsId = string.Format(CultureInfo.InvariantCulture, u = x;
"{0},{1:0.########},{2:0.########},{3:0.########}", crsId, scaleFactor, centerLongitude, centerLatitude); v = -y;
ScaleFactor = scaleFactor; }
CentralMeridian = centerLongitude; else if (LatitudeOfOrigin == -90d) // (20-17)
LatitudeOfOrigin = centerLatitude; {
u = x;
v = y;
}
else // (20-15)
{
u = x * sinC;
v = rho * cosPhi0 * cosC - y * sinPhi0 * sinC;
} }
private void GetScaleAndGridConvergence(double latitude, double longitude, out double scale, out double gamma) return new Location(
{ phi * 180d / Math.PI,
var phi0 = LatitudeOfOrigin * Math.PI / 180d; // φ1 Math.Atan2(u, v) * 180d / Math.PI + CentralMeridian);
var phi1 = latitude * Math.PI / 180d;
var phi2 = (latitude + 1e-3) * Math.PI / 180d;
var lambda = (longitude - CentralMeridian) * Math.PI / 180d; // λ - λ0
var sinPhi0 = Math.Sin(phi0);
var cosPhi0 = Math.Cos(phi0);
var sinPhi1 = Math.Sin(phi1);
var cosPhi1 = Math.Cos(phi1);
var sinPhi2 = Math.Sin(phi2);
var cosPhi2 = Math.Cos(phi2);
var sinLambda = Math.Sin(lambda);
var cosLambda = Math.Cos(lambda);
var k1 = 2d / (1d + sinPhi0 * sinPhi1 + cosPhi0 * cosPhi1 * cosLambda);
var k2 = 2d / (1d + sinPhi0 * sinPhi2 + cosPhi0 * cosPhi2 * cosLambda);
var c = k2 * cosPhi2 - k1 * cosPhi1;
var s = k2 * sinPhi2 - k1 * sinPhi1;
scale = k1;
gamma = Math.Atan2(-sinLambda * c, cosPhi0 * s - sinPhi0 * cosLambda * c) * 180d / Math.PI;
}
public override double GridConvergence(double latitude, double longitude)
{
GetScaleAndGridConvergence(latitude, longitude, out double _, out double gamma);
return gamma;
}
public override Matrix RelativeTransform(double latitude, double longitude)
{
GetScaleAndGridConvergence(latitude, longitude, out double scale, out double gamma);
var transform = new Matrix(scale, 0d, 0d, scale, 0d, 0d);
transform.Rotate(-gamma);
return transform;
}
public override Point LocationToMap(double latitude, double longitude)
{
var phi0 = LatitudeOfOrigin * Math.PI / 180d; // φ1
var phi = latitude * Math.PI / 180d; // φ
var lambda = (longitude - CentralMeridian) * Math.PI / 180d; // λ - λ0
var sinPhi0 = Math.Sin(phi0);
var cosPhi0 = Math.Cos(phi0);
var sinPhi = Math.Sin(phi);
var cosPhi = Math.Cos(phi);
var sinLambda = Math.Sin(lambda);
var cosPhiCosLambda = cosPhi * Math.Cos(lambda);
var x = cosPhi * sinLambda;
var y = cosPhi0 * sinPhi - sinPhi0 * cosPhiCosLambda;
var k = 2d / (1d + sinPhi0 * sinPhi + cosPhi0 * cosPhiCosLambda); // p.157 (21-4), k0 == 1
return new Point(
EquatorialRadius * k * x,
EquatorialRadius * k * y); // p.157 (21-2/3)
}
public override Location MapToLocation(double x, double y)
{
var rho = Math.Sqrt(x * x + y * y);
var c = 2d * Math.Atan(rho / (2d * EquatorialRadius)); // p.159 (21-15), k0 == 1
var cosC = Math.Cos(c);
var sinC = Math.Sin(c);
var phi0 = LatitudeOfOrigin * Math.PI / 180d; // φ1
var cosPhi0 = Math.Cos(phi0);
var sinPhi0 = Math.Sin(phi0);
var phi = Math.Asin(cosC * sinPhi0 + y * sinC * cosPhi0 / rho); // (20-14)
double u, v;
if (LatitudeOfOrigin == 90d) // (20-16)
{
u = x;
v = -y;
}
else if (LatitudeOfOrigin == -90d) // (20-17)
{
u = x;
v = y;
}
else // (20-15)
{
u = x * sinC;
v = rho * cosPhi0 * cosC - y * sinPhi0 * sinC;
}
return new Location(
phi * 180d / Math.PI,
Math.Atan2(u, v) * 180d / Math.PI + CentralMeridian);
}
} }
} }

View file

@ -10,21 +10,20 @@ using Microsoft.UI.Xaml.Media;
using ImageSource = Avalonia.Media.IImage; using ImageSource = Avalonia.Media.IImage;
#endif #endif
namespace MapControl namespace MapControl;
public abstract class Tile(int zoomLevel, int x, int y, int columnCount)
{ {
public abstract class Tile(int zoomLevel, int x, int y, int columnCount) public int ZoomLevel => zoomLevel;
{ public int X => x;
public int ZoomLevel => zoomLevel; public int Y => y;
public int X => x; public int Row => y;
public int Y => y; public int Column { get; } = ((x % columnCount) + columnCount) % columnCount;
public int Row => y;
public int Column { get; } = ((x % columnCount) + columnCount) % columnCount;
public bool IsPending { get; set; } = true; public bool IsPending { get; set; } = true;
/// <summary> /// <summary>
/// Runs a tile image download Task and passes the result to the UI thread. /// Runs a tile image download Task and passes the result to the UI thread.
/// </summary> /// </summary>
public abstract Task LoadImageAsync(Func<Task<ImageSource>> loadImageFunc); public abstract Task LoadImageAsync(Func<Task<ImageSource>> loadImageFunc);
}
} }

View file

@ -8,225 +8,224 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace MapControl namespace MapControl;
/// <summary>
/// Loads and optionally caches map tile images for a MapTilePyramidLayer.
/// </summary>
public interface ITileImageLoader
{ {
/// <summary> /// <summary>
/// Loads and optionally caches map tile images for a MapTilePyramidLayer. /// Starts asynchronous loading of all pending tiles from the tiles collection.
/// Caching is enabled when cacheName is a non-empty string and tiles are loaded from http or https Uris.
/// </summary> /// </summary>
public interface ITileImageLoader void BeginLoadTiles(IEnumerable<Tile> tiles, TileSource tileSource, string cacheName, IProgress<double> progress);
{
/// <summary>
/// Starts asynchronous loading of all pending tiles from the tiles collection.
/// Caching is enabled when cacheName is a non-empty string and tiles are loaded from http or https Uris.
/// </summary>
void BeginLoadTiles(IEnumerable<Tile> tiles, TileSource tileSource, string cacheName, IProgress<double> progress);
/// <summary> /// <summary>
/// Terminates all running tile loading tasks. /// Terminates all running tile loading tasks.
/// </summary> /// </summary>
void CancelLoadTiles(); void CancelLoadTiles();
}
public class TileImageLoader : ITileImageLoader
{
private static ILogger Logger => field ??= ImageLoader.LoggerFactory?.CreateLogger(typeof(TileImageLoader));
private readonly Queue<Tile> tileQueue = new Queue<Tile>();
private int tileCount;
private int taskCount;
/// <summary>
/// Default folder path where a persistent cache implementation may save data, i.e. "C:\ProgramData\MapControl\TileCache".
/// </summary>
public static string DefaultCacheFolder =>
#if UWP
Path.Combine(Windows.Storage.ApplicationData.Current.LocalCacheFolder.Path, "TileCache");
#else
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "MapControl", "TileCache");
#endif
/// <summary>
/// An IDistributedCache implementation used to cache tile images.
/// The default value is a MemoryDistributedCache instance.
/// </summary>
public static IDistributedCache Cache { get; set; } = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
/// <summary>
/// Default expiration time for cached tile images. Used when no expiration time
/// was transmitted on download. The default value is one day.
/// </summary>
public static TimeSpan DefaultCacheExpiration { get; set; } = TimeSpan.FromDays(1);
/// <summary>
/// Minimum expiration time for cached tile images. A transmitted expiration time
/// that falls below this value is ignored. The default value is TimeSpan.Zero.
/// </summary>
public static TimeSpan MinCacheExpiration { get; set; } = TimeSpan.Zero;
/// <summary>
/// Maximum expiration time for cached tile images. A transmitted expiration time
/// that exceeds this value is ignored. The default value is ten days.
/// </summary>
public static TimeSpan MaxCacheExpiration { get; set; } = TimeSpan.FromDays(10);
/// <summary>
/// Maximum number of parallel tile loading tasks. The default value is 4.
/// </summary>
public int MaxLoadTasks { get; set; } = 4;
public void BeginLoadTiles(IEnumerable<Tile> tiles, TileSource tileSource, string cacheName, IProgress<double> progress)
{
if (Cache == null)
{
cacheName = null; // disable caching
}
lock (tileQueue)
{
tileQueue.Clear();
foreach (var tile in tiles.Where(tile => tile.IsPending))
{
tileQueue.Enqueue(tile);
}
tileCount = tileQueue.Count;
var maxTasks = Math.Min(tileCount, MaxLoadTasks);
while (taskCount < maxTasks)
{
taskCount++;
Logger?.LogDebug("Task count: {count}", taskCount);
_ = Task.Run(() => LoadTilesFromQueue(tileSource, cacheName, progress));
}
}
} }
public class TileImageLoader : ITileImageLoader public void CancelLoadTiles()
{ {
private static ILogger Logger => field ??= ImageLoader.LoggerFactory?.CreateLogger(typeof(TileImageLoader)); lock (tileQueue)
private readonly Queue<Tile> tileQueue = new Queue<Tile>();
private int tileCount;
private int taskCount;
/// <summary>
/// Default folder path where a persistent cache implementation may save data, i.e. "C:\ProgramData\MapControl\TileCache".
/// </summary>
public static string DefaultCacheFolder =>
#if UWP
Path.Combine(Windows.Storage.ApplicationData.Current.LocalCacheFolder.Path, "TileCache");
#else
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "MapControl", "TileCache");
#endif
/// <summary>
/// An IDistributedCache implementation used to cache tile images.
/// The default value is a MemoryDistributedCache instance.
/// </summary>
public static IDistributedCache Cache { get; set; } = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
/// <summary>
/// Default expiration time for cached tile images. Used when no expiration time
/// was transmitted on download. The default value is one day.
/// </summary>
public static TimeSpan DefaultCacheExpiration { get; set; } = TimeSpan.FromDays(1);
/// <summary>
/// Minimum expiration time for cached tile images. A transmitted expiration time
/// that falls below this value is ignored. The default value is TimeSpan.Zero.
/// </summary>
public static TimeSpan MinCacheExpiration { get; set; } = TimeSpan.Zero;
/// <summary>
/// Maximum expiration time for cached tile images. A transmitted expiration time
/// that exceeds this value is ignored. The default value is ten days.
/// </summary>
public static TimeSpan MaxCacheExpiration { get; set; } = TimeSpan.FromDays(10);
/// <summary>
/// Maximum number of parallel tile loading tasks. The default value is 4.
/// </summary>
public int MaxLoadTasks { get; set; } = 4;
public void BeginLoadTiles(IEnumerable<Tile> tiles, TileSource tileSource, string cacheName, IProgress<double> progress)
{ {
if (Cache == null) tileQueue.Clear();
{ tileCount = 0;
cacheName = null; // disable caching
}
lock (tileQueue)
{
tileQueue.Clear();
foreach (var tile in tiles.Where(tile => tile.IsPending))
{
tileQueue.Enqueue(tile);
}
tileCount = tileQueue.Count;
var maxTasks = Math.Min(tileCount, MaxLoadTasks);
while (taskCount < maxTasks)
{
taskCount++;
Logger?.LogDebug("Task count: {count}", taskCount);
_ = Task.Run(() => LoadTilesFromQueue(tileSource, cacheName, progress));
}
}
} }
}
public void CancelLoadTiles() private async Task LoadTilesFromQueue(TileSource tileSource, string cacheName, IProgress<double> progress)
{
bool TryDequeueTile(out Tile tile)
{ {
lock (tileQueue) lock (tileQueue)
{ {
tileQueue.Clear(); if (tileQueue.Count > 0)
tileCount = 0; {
tile = tileQueue.Dequeue();
tile.IsPending = false;
progress?.Report(1d - (double)tileQueue.Count / tileCount);
return true;
}
taskCount--;
Logger?.LogDebug("Task count: {count}", taskCount);
} }
tile = null;
return false;
} }
private async Task LoadTilesFromQueue(TileSource tileSource, string cacheName, IProgress<double> progress) while (TryDequeueTile(out Tile tile))
{ {
bool TryDequeueTile(out Tile tile)
{
lock (tileQueue)
{
if (tileQueue.Count > 0)
{
tile = tileQueue.Dequeue();
tile.IsPending = false;
progress?.Report(1d - (double)tileQueue.Count / tileCount);
return true;
}
taskCount--;
Logger?.LogDebug("Task count: {count}", taskCount);
}
tile = null;
return false;
}
while (TryDequeueTile(out Tile tile))
{
try
{
Logger?.LogDebug("Thread {thread,2}: Loading tile ({zoom}/{column}/{row})",
Environment.CurrentManagedThreadId, tile.ZoomLevel, tile.Column, tile.Row);
await LoadTileImage(tile, tileSource, cacheName);
}
catch (Exception ex)
{
Logger?.LogError(ex, "Failed loading tile {zoom}/{column}/{row}", tile.ZoomLevel, tile.Column, tile.Row);
}
}
}
private static async Task LoadTileImage(Tile tile, TileSource tileSource, string cacheName)
{
// Pass image loading callbacks to platform-specific method
// tile.LoadImageAsync(Func<Task<ImageSource>>) for completion in the UI thread.
var uri = tileSource.GetUri(tile.ZoomLevel, tile.Column, tile.Row);
if (uri == null)
{
await tile.LoadImageAsync(() => tileSource.LoadImageAsync(tile.ZoomLevel, tile.Column, tile.Row)).ConfigureAwait(false);
}
else if (!uri.IsHttp() || string.IsNullOrEmpty(cacheName))
{
await tile.LoadImageAsync(() => tileSource.LoadImageAsync(uri)).ConfigureAwait(false);
}
else
{
var buffer = await LoadCachedBuffer(tile, uri, cacheName).ConfigureAwait(false);
if (buffer != null)
{
await tile.LoadImageAsync(() => tileSource.LoadImageAsync(buffer)).ConfigureAwait(false);
}
}
}
private static async Task<byte[]> LoadCachedBuffer(Tile tile, Uri uri, string cacheName)
{
var extension = Path.GetExtension(uri.LocalPath).ToLower();
if (string.IsNullOrEmpty(extension) || extension == ".jpeg")
{
extension = ".jpg";
}
var cacheKey = $"{cacheName}/{tile.ZoomLevel}/{tile.Column}/{tile.Row}{extension}";
byte[] buffer = null;
try try
{ {
buffer = await Cache.GetAsync(cacheKey).ConfigureAwait(false); Logger?.LogDebug("Thread {thread,2}: Loading tile ({zoom}/{column}/{row})",
Environment.CurrentManagedThreadId, tile.ZoomLevel, tile.Column, tile.Row);
await LoadTileImage(tile, tileSource, cacheName);
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger?.LogError(ex, "Cache.GetAsync({cacheKey})", cacheKey); Logger?.LogError(ex, "Failed loading tile {zoom}/{column}/{row}", tile.ZoomLevel, tile.Column, tile.Row);
} }
if (buffer == null)
{
using var response = await ImageLoader.GetHttpResponseAsync(uri).ConfigureAwait(false);
if (response != null)
{
buffer = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
try
{
var maxAge = response.Headers.CacheControl?.MaxAge;
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow =
!maxAge.HasValue ? DefaultCacheExpiration
: maxAge.Value < MinCacheExpiration ? MinCacheExpiration
: maxAge.Value > MaxCacheExpiration ? MaxCacheExpiration
: maxAge.Value
};
await Cache.SetAsync(cacheKey, buffer, options).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger?.LogError(ex, "Cache.SetAsync({cacheKey})", cacheKey);
}
}
}
return buffer;
} }
} }
private static async Task LoadTileImage(Tile tile, TileSource tileSource, string cacheName)
{
// Pass image loading callbacks to platform-specific method
// tile.LoadImageAsync(Func<Task<ImageSource>>) for completion in the UI thread.
var uri = tileSource.GetUri(tile.ZoomLevel, tile.Column, tile.Row);
if (uri == null)
{
await tile.LoadImageAsync(() => tileSource.LoadImageAsync(tile.ZoomLevel, tile.Column, tile.Row)).ConfigureAwait(false);
}
else if (!uri.IsHttp() || string.IsNullOrEmpty(cacheName))
{
await tile.LoadImageAsync(() => tileSource.LoadImageAsync(uri)).ConfigureAwait(false);
}
else
{
var buffer = await LoadCachedBuffer(tile, uri, cacheName).ConfigureAwait(false);
if (buffer != null)
{
await tile.LoadImageAsync(() => tileSource.LoadImageAsync(buffer)).ConfigureAwait(false);
}
}
}
private static async Task<byte[]> LoadCachedBuffer(Tile tile, Uri uri, string cacheName)
{
var extension = Path.GetExtension(uri.LocalPath).ToLower();
if (string.IsNullOrEmpty(extension) || extension == ".jpeg")
{
extension = ".jpg";
}
var cacheKey = $"{cacheName}/{tile.ZoomLevel}/{tile.Column}/{tile.Row}{extension}";
byte[] buffer = null;
try
{
buffer = await Cache.GetAsync(cacheKey).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger?.LogError(ex, "Cache.GetAsync({cacheKey})", cacheKey);
}
if (buffer == null)
{
using var response = await ImageLoader.GetHttpResponseAsync(uri).ConfigureAwait(false);
if (response != null)
{
buffer = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
try
{
var maxAge = response.Headers.CacheControl?.MaxAge;
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow =
!maxAge.HasValue ? DefaultCacheExpiration
: maxAge.Value < MinCacheExpiration ? MinCacheExpiration
: maxAge.Value > MaxCacheExpiration ? MaxCacheExpiration
: maxAge.Value
};
await Cache.SetAsync(cacheKey, buffer, options).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger?.LogError(ex, "Cache.SetAsync({cacheKey})", cacheKey);
}
}
}
return buffer;
}
} }

View file

@ -1,13 +1,12 @@
namespace MapControl namespace MapControl;
public class TileMatrix(int zoomLevel, int xMin, int yMin, int xMax, int yMax)
{ {
public class TileMatrix(int zoomLevel, int xMin, int yMin, int xMax, int yMax) public int ZoomLevel => zoomLevel;
{ public int XMin => xMin;
public int ZoomLevel => zoomLevel; public int YMin => yMin;
public int XMin => xMin; public int XMax => xMax;
public int YMin => yMin; public int YMax => yMax;
public int XMax => xMax; public int Width => xMax - xMin + 1;
public int YMax => yMax; public int Height => yMax - yMin + 1;
public int Width => xMax - xMin + 1;
public int Height => yMax - yMin + 1;
}
} }

View file

@ -21,188 +21,187 @@ using Avalonia.Controls;
using Brush = Avalonia.Media.IBrush; using Brush = Avalonia.Media.IBrush;
#endif #endif
namespace MapControl namespace MapControl;
public abstract class TilePyramidLayer : Panel, IMapLayer
{ {
public abstract class TilePyramidLayer : Panel, IMapLayer public static readonly DependencyProperty SourceNameProperty =
DependencyPropertyHelper.Register<TilePyramidLayer, string>(nameof(SourceName));
public static readonly DependencyProperty DescriptionProperty =
DependencyPropertyHelper.Register<TilePyramidLayer, string>(nameof(Description));
public static readonly DependencyProperty MaxBackgroundLevelsProperty =
DependencyPropertyHelper.Register<TilePyramidLayer, int>(nameof(MaxBackgroundLevels), 5);
public static readonly DependencyProperty UpdateIntervalProperty =
DependencyPropertyHelper.Register<TilePyramidLayer, TimeSpan>(nameof(UpdateInterval), TimeSpan.FromSeconds(0.2),
(layer, oldValue, newValue) => layer.updateTimer.Interval = newValue);
public static readonly DependencyProperty UpdateWhileViewportChangingProperty =
DependencyPropertyHelper.Register<TilePyramidLayer, bool>(nameof(UpdateWhileViewportChanging));
public static readonly DependencyProperty MapBackgroundProperty =
DependencyPropertyHelper.Register<TilePyramidLayer, Brush>(nameof(MapBackground));
public static readonly DependencyProperty MapForegroundProperty =
DependencyPropertyHelper.Register<TilePyramidLayer, Brush>(nameof(MapForeground));
public static readonly DependencyProperty LoadingProgressProperty =
DependencyPropertyHelper.Register<TilePyramidLayer, double>(nameof(LoadingProgress), 1d);
private readonly Progress<double> loadingProgress;
private readonly UpdateTimer updateTimer;
protected TilePyramidLayer()
{ {
public static readonly DependencyProperty SourceNameProperty = IsHitTestVisible = false;
DependencyPropertyHelper.Register<TilePyramidLayer, string>(nameof(SourceName));
public static readonly DependencyProperty DescriptionProperty = loadingProgress = new Progress<double>(p => SetValue(LoadingProgressProperty, p));
DependencyPropertyHelper.Register<TilePyramidLayer, string>(nameof(Description));
public static readonly DependencyProperty MaxBackgroundLevelsProperty = updateTimer = new UpdateTimer { Interval = UpdateInterval };
DependencyPropertyHelper.Register<TilePyramidLayer, int>(nameof(MaxBackgroundLevels), 5); updateTimer.Tick += (_, _) => UpdateTiles();
public static readonly DependencyProperty UpdateIntervalProperty =
DependencyPropertyHelper.Register<TilePyramidLayer, TimeSpan>(nameof(UpdateInterval), TimeSpan.FromSeconds(0.2),
(layer, oldValue, newValue) => layer.updateTimer.Interval = newValue);
public static readonly DependencyProperty UpdateWhileViewportChangingProperty =
DependencyPropertyHelper.Register<TilePyramidLayer, bool>(nameof(UpdateWhileViewportChanging));
public static readonly DependencyProperty MapBackgroundProperty =
DependencyPropertyHelper.Register<TilePyramidLayer, Brush>(nameof(MapBackground));
public static readonly DependencyProperty MapForegroundProperty =
DependencyPropertyHelper.Register<TilePyramidLayer, Brush>(nameof(MapForeground));
public static readonly DependencyProperty LoadingProgressProperty =
DependencyPropertyHelper.Register<TilePyramidLayer, double>(nameof(LoadingProgress), 1d);
private readonly Progress<double> loadingProgress;
private readonly UpdateTimer updateTimer;
protected TilePyramidLayer()
{
IsHitTestVisible = false;
loadingProgress = new Progress<double>(p => SetValue(LoadingProgressProperty, p));
updateTimer = new UpdateTimer { Interval = UpdateInterval };
updateTimer.Tick += (_, _) => UpdateTiles();
#if WPF #if WPF
RenderOptions.SetEdgeMode(this, EdgeMode.Aliased); RenderOptions.SetEdgeMode(this, EdgeMode.Aliased);
#elif UWP || WINUI #elif UWP || WINUI
ElementCompositionPreview.GetElementVisual(this).BorderMode = CompositionBorderMode.Hard; ElementCompositionPreview.GetElementVisual(this).BorderMode = CompositionBorderMode.Hard;
MapPanel.InitMapElement(this); MapPanel.InitMapElement(this);
#endif #endif
} }
public ITileImageLoader TileImageLoader public ITileImageLoader TileImageLoader
{
get => field ??= new TileImageLoader();
set;
}
/// <summary>
/// Name of the tile source that is used as component of a tile cache key.
/// Tile images are not cached when SourceName is null or empty.
/// </summary>
public string SourceName
{
get => (string)GetValue(SourceNameProperty);
set => SetValue(SourceNameProperty, value);
}
/// <summary>
/// Description of the layer. Used to display copyright information on top of the map.
/// </summary>
public string Description
{
get => (string)GetValue(DescriptionProperty);
set => SetValue(DescriptionProperty, value);
}
/// <summary>
/// Maximum number of background tile levels. Default value is 5.
/// Only effective in a MapTileLayer or WmtsTileLayer that is the MapLayer of its ParentMap.
/// </summary>
public int MaxBackgroundLevels
{
get => (int)GetValue(MaxBackgroundLevelsProperty);
set => SetValue(MaxBackgroundLevelsProperty, value);
}
/// <summary>
/// Minimum time interval between tile updates.
/// </summary>
public TimeSpan UpdateInterval
{
get => (TimeSpan)GetValue(UpdateIntervalProperty);
set => SetValue(UpdateIntervalProperty, value);
}
/// <summary>
/// Controls if tiles are updated while the viewport is still changing.
/// </summary>
public bool UpdateWhileViewportChanging
{
get => (bool)GetValue(UpdateWhileViewportChangingProperty);
set => SetValue(UpdateWhileViewportChangingProperty, value);
}
/// <summary>
/// Optional background brush. Sets MapBase.Background if not null and this layer is the base map layer.
/// </summary>
public Brush MapBackground
{
get => (Brush)GetValue(MapBackgroundProperty);
set => SetValue(MapBackgroundProperty, value);
}
/// <summary>
/// Optional foreground brush. Sets MapBase.Foreground if not null and this layer is the base map layer.
/// </summary>
public Brush MapForeground
{
get => (Brush)GetValue(MapForegroundProperty);
set => SetValue(MapForegroundProperty, value);
}
/// <summary>
/// Gets the progress of the TileImageLoader as a double value between 0 and 1.
/// </summary>
public double LoadingProgress => (double)GetValue(LoadingProgressProperty);
/// <summary>
/// Implements IMapElement.ParentMap.
/// </summary>
public MapBase ParentMap
{
get;
set
{ {
get => field ??= new TileImageLoader(); if (field != null)
set;
}
/// <summary>
/// Name of the tile source that is used as component of a tile cache key.
/// Tile images are not cached when SourceName is null or empty.
/// </summary>
public string SourceName
{
get => (string)GetValue(SourceNameProperty);
set => SetValue(SourceNameProperty, value);
}
/// <summary>
/// Description of the layer. Used to display copyright information on top of the map.
/// </summary>
public string Description
{
get => (string)GetValue(DescriptionProperty);
set => SetValue(DescriptionProperty, value);
}
/// <summary>
/// Maximum number of background tile levels. Default value is 5.
/// Only effective in a MapTileLayer or WmtsTileLayer that is the MapLayer of its ParentMap.
/// </summary>
public int MaxBackgroundLevels
{
get => (int)GetValue(MaxBackgroundLevelsProperty);
set => SetValue(MaxBackgroundLevelsProperty, value);
}
/// <summary>
/// Minimum time interval between tile updates.
/// </summary>
public TimeSpan UpdateInterval
{
get => (TimeSpan)GetValue(UpdateIntervalProperty);
set => SetValue(UpdateIntervalProperty, value);
}
/// <summary>
/// Controls if tiles are updated while the viewport is still changing.
/// </summary>
public bool UpdateWhileViewportChanging
{
get => (bool)GetValue(UpdateWhileViewportChangingProperty);
set => SetValue(UpdateWhileViewportChangingProperty, value);
}
/// <summary>
/// Optional background brush. Sets MapBase.Background if not null and this layer is the base map layer.
/// </summary>
public Brush MapBackground
{
get => (Brush)GetValue(MapBackgroundProperty);
set => SetValue(MapBackgroundProperty, value);
}
/// <summary>
/// Optional foreground brush. Sets MapBase.Foreground if not null and this layer is the base map layer.
/// </summary>
public Brush MapForeground
{
get => (Brush)GetValue(MapForegroundProperty);
set => SetValue(MapForegroundProperty, value);
}
/// <summary>
/// Gets the progress of the TileImageLoader as a double value between 0 and 1.
/// </summary>
public double LoadingProgress => (double)GetValue(LoadingProgressProperty);
/// <summary>
/// Implements IMapElement.ParentMap.
/// </summary>
public MapBase ParentMap
{
get;
set
{ {
if (field != null) field.ViewportChanged -= OnViewportChanged;
{
field.ViewportChanged -= OnViewportChanged;
}
field = value;
if (field != null)
{
field.ViewportChanged += OnViewportChanged;
}
updateTimer.Run();
} }
}
public bool IsBaseMapLayer => ParentMap != null && ParentMap.Children.Count > 0 && ParentMap.Children[0] == this; field = value;
protected void BeginLoadTiles(IEnumerable<Tile> tiles, TileSource tileSource, string cacheName) if (field != null)
{
TileImageLoader.BeginLoadTiles(tiles, tileSource, cacheName, loadingProgress);
}
protected void CancelLoadTiles()
{
TileImageLoader.CancelLoadTiles();
ClearValue(LoadingProgressProperty);
}
protected abstract void UpdateRenderTransform();
protected abstract void UpdateTileCollection();
private void UpdateTiles()
{
updateTimer.Stop();
UpdateTileCollection();
}
private void OnViewportChanged(object sender, ViewportChangedEventArgs e)
{
if (e.TransformCenterChanged || e.ProjectionChanged || Children.Count == 0)
{ {
UpdateTiles(); // update immediately field.ViewportChanged += OnViewportChanged;
}
else
{
UpdateRenderTransform();
updateTimer.Run(!UpdateWhileViewportChanging);
} }
updateTimer.Run();
}
}
public bool IsBaseMapLayer => ParentMap != null && ParentMap.Children.Count > 0 && ParentMap.Children[0] == this;
protected void BeginLoadTiles(IEnumerable<Tile> tiles, TileSource tileSource, string cacheName)
{
TileImageLoader.BeginLoadTiles(tiles, tileSource, cacheName, loadingProgress);
}
protected void CancelLoadTiles()
{
TileImageLoader.CancelLoadTiles();
ClearValue(LoadingProgressProperty);
}
protected abstract void UpdateRenderTransform();
protected abstract void UpdateTileCollection();
private void UpdateTiles()
{
updateTimer.Stop();
UpdateTileCollection();
}
private void OnViewportChanged(object sender, ViewportChangedEventArgs e)
{
if (e.TransformCenterChanged || e.ProjectionChanged || Children.Count == 0)
{
UpdateTiles(); // update immediately
}
else
{
UpdateRenderTransform();
updateTimer.Run(!UpdateWhileViewportChanging);
} }
} }
} }

View file

@ -10,60 +10,59 @@ using Microsoft.UI.Xaml.Media;
using ImageSource = Avalonia.Media.IImage; using ImageSource = Avalonia.Media.IImage;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Provides the download Uri or ImageSource of map tiles. Used by TileImageLoader.
/// </summary>
#if UWP || WINUI
[Windows.Foundation.Metadata.CreateFromString(MethodName = "Parse")]
#else
[System.ComponentModel.TypeConverter(typeof(TileSourceConverter))]
#endif
public class TileSource
{ {
/// <summary> /// <summary>
/// Provides the download Uri or ImageSource of map tiles. Used by TileImageLoader. /// Gets an image request Uri for the specified zoom level and tile indices.
/// May return null when the image shall be loaded by
/// the LoadImageAsync(zoomLevel, column, row) method.
/// </summary> /// </summary>
#if UWP || WINUI public virtual Uri GetUri(int zoomLevel, int column, int row)
[Windows.Foundation.Metadata.CreateFromString(MethodName = "Parse")]
#else
[System.ComponentModel.TypeConverter(typeof(TileSourceConverter))]
#endif
public class TileSource
{ {
/// <summary> return null;
/// Gets an image request Uri for the specified zoom level and tile indices. }
/// May return null when the image shall be loaded by
/// the LoadImageAsync(zoomLevel, column, row) method.
/// </summary>
public virtual Uri GetUri(int zoomLevel, int column, int row)
{
return null;
}
/// <summary> /// <summary>
/// Loads a tile image without an Uri. Called when GetUri returns null. /// Loads a tile image without an Uri. Called when GetUri returns null.
/// </summary> /// </summary>
public virtual Task<ImageSource> LoadImageAsync(int zoomLevel, int column, int row) public virtual Task<ImageSource> LoadImageAsync(int zoomLevel, int column, int row)
{ {
return null; return null;
} }
/// <summary> /// <summary>
/// Loads a tile image from an Uri. Called when the Uri scheme is neither /// Loads a tile image from an Uri. Called when the Uri scheme is neither
/// http nor https or when the TileImageLoader is not using an image cache. /// http nor https or when the TileImageLoader is not using an image cache.
/// </summary> /// </summary>
public virtual Task<ImageSource> LoadImageAsync(Uri uri) public virtual Task<ImageSource> LoadImageAsync(Uri uri)
{ {
return ImageLoader.LoadImageAsync(uri); return ImageLoader.LoadImageAsync(uri);
} }
/// <summary> /// <summary>
/// Loads a tile image from an encoded image buffer. Called when the /// Loads a tile image from an encoded image buffer. Called when the
/// TileImageLoader caches image buffers from http or https requests. /// TileImageLoader caches image buffers from http or https requests.
/// </summary> /// </summary>
public virtual Task<ImageSource> LoadImageAsync(byte[] buffer) public virtual Task<ImageSource> LoadImageAsync(byte[] buffer)
{ {
return ImageLoader.LoadImageAsync(buffer); return ImageLoader.LoadImageAsync(buffer);
} }
/// <summary> /// <summary>
/// Creates a TileSource instance from an Uri template string. /// Creates a TileSource instance from an Uri template string.
/// </summary> /// </summary>
public static TileSource Parse(string uriTemplate) public static TileSource Parse(string uriTemplate)
{ {
return new UriTileSource { UriTemplate = uriTemplate }; return new UriTileSource { UriTemplate = uriTemplate };
}
} }
} }

View file

@ -6,165 +6,164 @@ using System.Windows.Media;
using Avalonia; using Avalonia;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Transverse Mercator Projection. See
/// https://en.wikipedia.org/wiki/Transverse_Mercator_projection,
/// https://en.wikipedia.org/wiki/Universal_Transverse_Mercator_coordinate_system,
/// https://en.wikipedia.org/wiki/Transverse_Mercator_projection#Convergence.
/// </summary>
public class TransverseMercatorProjection : MapProjection
{ {
/// <summary> private readonly double n;
/// Transverse Mercator Projection. See private readonly double m; // 2*sqrt(n)/(1+n)
/// https://en.wikipedia.org/wiki/Transverse_Mercator_projection, private readonly double a1; // α1
/// https://en.wikipedia.org/wiki/Universal_Transverse_Mercator_coordinate_system, private readonly double a2; // α2
/// https://en.wikipedia.org/wiki/Transverse_Mercator_projection#Convergence. private readonly double a3; // α3
/// </summary> private readonly double b1; // β1
public class TransverseMercatorProjection : MapProjection private readonly double b2; // β2
private readonly double b3; // β3
private readonly double d1; // δ1
private readonly double d2; // δ2
private readonly double d3; // δ3
private readonly double A;
protected TransverseMercatorProjection(double equatorialRadius, double flattening)
{ {
private readonly double n; EquatorialRadius = equatorialRadius;
private readonly double m; // 2*sqrt(n)/(1+n) Flattening = flattening;
private readonly double a1; // α1
private readonly double a2; // α2
private readonly double a3; // α3
private readonly double b1; // β1
private readonly double b2; // β2
private readonly double b3; // β3
private readonly double d1; // δ1
private readonly double d2; // δ2
private readonly double d3; // δ3
private readonly double A;
protected TransverseMercatorProjection(double equatorialRadius, double flattening) n = flattening / (2d - flattening);
{ m = 2d * Math.Sqrt(n) / (1d + n);
EquatorialRadius = equatorialRadius; var n2 = n * n;
Flattening = flattening; var n3 = n * n2;
n = flattening / (2d - flattening); a1 = n / 2d - n2 * 2d / 3d + n3 * 5d / 16d;
m = 2d * Math.Sqrt(n) / (1d + n); a2 = n2 * 13d / 48d - n3 * 3d / 5d;
var n2 = n * n; a3 = n3 * 61d / 240d;
var n3 = n * n2; b1 = n / 2d - n2 * 2d / 3d + n3 * 37d / 96d;
b2 = n2 / 48d + n3 / 15d;
b3 = n3 * 17d / 480d;
d1 = n * 2d - n2 * 2d / 3d - n3 * 2d;
d2 = n2 * 7d / 3d - n3 * 8d / 5d;
d3 = n3 * 56d / 15d;
A = equatorialRadius / (1d + n) * (1d + n2 / 4d + n2 * n2 / 64d);
}
a1 = n / 2d - n2 * 2d / 3d + n3 * 5d / 16d; public override double GridConvergence(double latitude, double longitude)
a2 = n2 * 13d / 48d - n3 * 3d / 5d; {
a3 = n3 * 61d / 240d; // φ
b1 = n / 2d - n2 * 2d / 3d + n3 * 37d / 96d; var phi = latitude * Math.PI / 180d;
b2 = n2 / 48d + n3 / 15d; // λ - λ0
b3 = n3 * 17d / 480d; var lambda = (longitude - CentralMeridian) * Math.PI / 180d;
d1 = n * 2d - n2 * 2d / 3d - n3 * 2d;
d2 = n2 * 7d / 3d - n3 * 8d / 5d;
d3 = n3 * 56d / 15d;
A = equatorialRadius / (1d + n) * (1d + n2 / 4d + n2 * n2 / 64d);
}
public override double GridConvergence(double latitude, double longitude) // γ calculation for the sphere is sufficiently accurate
{ //
// φ return Math.Atan(Math.Tan(lambda) * Math.Sin(phi)) * 180d / Math.PI;
var phi = latitude * Math.PI / 180d; }
// λ - λ0
var lambda = (longitude - CentralMeridian) * Math.PI / 180d;
// γ calculation for the sphere is sufficiently accurate public override Matrix RelativeTransform(double latitude, double longitude)
// {
return Math.Atan(Math.Tan(lambda) * Math.Sin(phi)) * 180d / Math.PI; // φ
} var phi = latitude * Math.PI / 180d;
var sinPhi = Math.Sin(phi);
// λ - λ0
var lambda = (longitude - CentralMeridian) * Math.PI / 180d;
var cosLambda = Math.Cos(lambda);
var tanLambda = Math.Tan(lambda);
// t
var t = Math.Sinh(Atanh(sinPhi) - m * Atanh(m * sinPhi));
var u = Math.Sqrt(1d + t * t);
// ξ'
var xi_ = Math.Atan2(t, cosLambda);
// η'
var eta_ = Atanh(Math.Sin(lambda) / u);
// σ
var sigma = 1 +
2d * a1 * Math.Cos(2d * xi_) * Math.Cosh(2d * eta_) +
4d * a2 * Math.Cos(4d * xi_) * Math.Cosh(4d * eta_) +
6d * a3 * Math.Cos(6d * xi_) * Math.Cosh(6d * eta_);
// τ
var tau =
2d * a1 * Math.Sin(2d * xi_) * Math.Sinh(2d * eta_) +
4d * a2 * Math.Sin(4d * xi_) * Math.Sinh(4d * eta_) +
6d * a3 * Math.Sin(6d * xi_) * Math.Sinh(6d * eta_);
public override Matrix RelativeTransform(double latitude, double longitude) var q = (1d - n) / (1d + n) * Math.Tan(phi);
{ var k = ScaleFactor * A / EquatorialRadius
// φ * Math.Sqrt((1d + q * q) * (sigma * sigma + tau * tau) / (t * t + cosLambda * cosLambda));
var phi = latitude * Math.PI / 180d;
var sinPhi = Math.Sin(phi);
// λ - λ0
var lambda = (longitude - CentralMeridian) * Math.PI / 180d;
var cosLambda = Math.Cos(lambda);
var tanLambda = Math.Tan(lambda);
// t
var t = Math.Sinh(Atanh(sinPhi) - m * Atanh(m * sinPhi));
var u = Math.Sqrt(1d + t * t);
// ξ'
var xi_ = Math.Atan2(t, cosLambda);
// η'
var eta_ = Atanh(Math.Sin(lambda) / u);
// σ
var sigma = 1 +
2d * a1 * Math.Cos(2d * xi_) * Math.Cosh(2d * eta_) +
4d * a2 * Math.Cos(4d * xi_) * Math.Cosh(4d * eta_) +
6d * a3 * Math.Cos(6d * xi_) * Math.Cosh(6d * eta_);
// τ
var tau =
2d * a1 * Math.Sin(2d * xi_) * Math.Sinh(2d * eta_) +
4d * a2 * Math.Sin(4d * xi_) * Math.Sinh(4d * eta_) +
6d * a3 * Math.Sin(6d * xi_) * Math.Sinh(6d * eta_);
var q = (1d - n) / (1d + n) * Math.Tan(phi); // γ, grid convergence
var k = ScaleFactor * A / EquatorialRadius var gamma = Math.Atan2(tau * u + sigma * t * tanLambda, sigma * u - tau * t * tanLambda);
* Math.Sqrt((1d + q * q) * (sigma * sigma + tau * tau) / (t * t + cosLambda * cosLambda));
// γ, grid convergence var transform = new Matrix(k, 0d, 0d, k, 0d, 0d);
var gamma = Math.Atan2(tau * u + sigma * t * tanLambda, sigma * u - tau * t * tanLambda); transform.Rotate(-gamma * 180d / Math.PI);
var transform = new Matrix(k, 0d, 0d, k, 0d, 0d); return transform;
transform.Rotate(-gamma * 180d / Math.PI); }
return transform; public override Point LocationToMap(double latitude, double longitude)
} {
// φ
var phi = latitude * Math.PI / 180d;
var sinPhi = Math.Sin(phi);
// t
var t = Math.Sinh(Atanh(sinPhi) - m * Atanh(m * sinPhi));
// λ - λ0
var lambda = (longitude - CentralMeridian) * Math.PI / 180d;
// ξ'
var xi_ = Math.Atan2(t, Math.Cos(lambda));
// η'
var eta_ = Atanh(Math.Sin(lambda) / Math.Sqrt(1d + t * t));
public override Point LocationToMap(double latitude, double longitude) var x = FalseEasting + ScaleFactor * A * (eta_ +
{ a1 * Math.Cos(2d * xi_) * Math.Sinh(2d * eta_) +
// φ a2 * Math.Cos(4d * xi_) * Math.Sinh(4d * eta_) +
var phi = latitude * Math.PI / 180d; a3 * Math.Cos(6d * xi_) * Math.Sinh(6d * eta_));
var sinPhi = Math.Sin(phi);
// t
var t = Math.Sinh(Atanh(sinPhi) - m * Atanh(m * sinPhi));
// λ - λ0
var lambda = (longitude - CentralMeridian) * Math.PI / 180d;
// ξ'
var xi_ = Math.Atan2(t, Math.Cos(lambda));
// η'
var eta_ = Atanh(Math.Sin(lambda) / Math.Sqrt(1d + t * t));
var x = FalseEasting + ScaleFactor * A * (eta_ + var y = FalseNorthing + ScaleFactor * A * (xi_ +
a1 * Math.Cos(2d * xi_) * Math.Sinh(2d * eta_) + a1 * Math.Sin(2d * xi_) * Math.Cosh(2d * eta_) +
a2 * Math.Cos(4d * xi_) * Math.Sinh(4d * eta_) + a2 * Math.Sin(4d * xi_) * Math.Cosh(4d * eta_) +
a3 * Math.Cos(6d * xi_) * Math.Sinh(6d * eta_)); a3 * Math.Sin(6d * xi_) * Math.Cosh(6d * eta_));
var y = FalseNorthing + ScaleFactor * A * (xi_ + return new Point(x, y);
a1 * Math.Sin(2d * xi_) * Math.Cosh(2d * eta_) + }
a2 * Math.Sin(4d * xi_) * Math.Cosh(4d * eta_) +
a3 * Math.Sin(6d * xi_) * Math.Cosh(6d * eta_));
return new Point(x, y); public override Location MapToLocation(double x, double y)
} {
// ξ
var xi = (y - FalseNorthing) / (ScaleFactor * A);
// η
var eta = (x - FalseEasting) / (ScaleFactor * A);
// ξ'
var xi_ = xi -
b1 * Math.Sin(2d * xi) * Math.Cosh(2d * eta) -
b2 * Math.Sin(4d * xi) * Math.Cosh(4d * eta) -
b3 * Math.Sin(6d * xi) * Math.Cosh(6d * eta);
// η'
var eta_ = eta -
b1 * Math.Cos(2d * xi) * Math.Sinh(2d * eta) -
b2 * Math.Cos(4d * xi) * Math.Sinh(4d * eta) -
b3 * Math.Cos(6d * xi) * Math.Sinh(6d * eta);
// χ
var chi = Math.Asin(Math.Sin(xi_) / Math.Cosh(eta_));
// φ
var phi = chi +
d1 * Math.Sin(2d * chi) +
d2 * Math.Sin(4d * chi) +
d3 * Math.Sin(6d * chi);
// λ - λ0
var lambda = Math.Atan2(Math.Sinh(eta_), Math.Cos(xi_));
public override Location MapToLocation(double x, double y) return new Location(
{ phi * 180d / Math.PI,
// ξ lambda * 180d / Math.PI + CentralMeridian);
var xi = (y - FalseNorthing) / (ScaleFactor * A); }
// η
var eta = (x - FalseEasting) / (ScaleFactor * A);
// ξ'
var xi_ = xi -
b1 * Math.Sin(2d * xi) * Math.Cosh(2d * eta) -
b2 * Math.Sin(4d * xi) * Math.Cosh(4d * eta) -
b3 * Math.Sin(6d * xi) * Math.Cosh(6d * eta);
// η'
var eta_ = eta -
b1 * Math.Cos(2d * xi) * Math.Sinh(2d * eta) -
b2 * Math.Cos(4d * xi) * Math.Sinh(4d * eta) -
b3 * Math.Cos(6d * xi) * Math.Sinh(6d * eta);
// χ
var chi = Math.Asin(Math.Sin(xi_) / Math.Cosh(eta_));
// φ
var phi = chi +
d1 * Math.Sin(2d * chi) +
d2 * Math.Sin(4d * chi) +
d3 * Math.Sin(6d * chi);
// λ - λ0
var lambda = Math.Atan2(Math.Sinh(eta_), Math.Cos(xi_));
return new Location(
phi * 180d / Math.PI,
lambda * 180d / Math.PI + CentralMeridian);
}
#if NETFRAMEWORK #if NETFRAMEWORK
private static double Atanh(double x) => Math.Log((1d + x) / (1d - x)) / 2d; private static double Atanh(double x) => Math.Log((1d + x) / (1d - x)) / 2d;
#else #else
private static double Atanh(double x) => Math.Atanh(x); private static double Atanh(double x) => Math.Atanh(x);
#endif #endif
}
} }

View file

@ -6,160 +6,159 @@ using System.Windows.Media;
using Avalonia; using Avalonia;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Elliptical Transverse Mercator Projection.
/// See "Map Projections - A Working Manual" (https://pubs.usgs.gov/publication/pp1395), p.60-64,
/// and https://en.wikipedia.org/wiki/Transverse_Mercator_projection#Convergence.
/// </summary>
public class TransverseMercatorProjectionSnyder : MapProjection
{ {
/// <summary> public override double GridConvergence(double latitude, double longitude)
/// Elliptical Transverse Mercator Projection.
/// See "Map Projections - A Working Manual" (https://pubs.usgs.gov/publication/pp1395), p.60-64,
/// and https://en.wikipedia.org/wiki/Transverse_Mercator_projection#Convergence.
/// </summary>
public class TransverseMercatorProjectionSnyder : MapProjection
{ {
public override double GridConvergence(double latitude, double longitude) // φ
var phi = latitude * Math.PI / 180d;
// λ - λ0
var lambda = (longitude - CentralMeridian) * Math.PI / 180d;
// γ calculation for the sphere is sufficiently accurate
//
return Math.Atan(Math.Tan(lambda) * Math.Sin(phi)) * 180d / Math.PI;
}
public override Matrix RelativeTransform(double latitude, double longitude)
{
var k = ScaleFactor;
var gamma = 0d; // γ
if (latitude > -90d && latitude < 90d)
{ {
// φ
var phi = latitude * Math.PI / 180d; var phi = latitude * Math.PI / 180d;
// λ - λ0 var sinPhi = Math.Sin(phi);
var cosPhi = Math.Cos(phi);
var tanPhi = sinPhi / cosPhi;
var lambda = (longitude - CentralMeridian) * Math.PI / 180d; var lambda = (longitude - CentralMeridian) * Math.PI / 180d;
// γ calculation for the sphere is sufficiently accurate
//
return Math.Atan(Math.Tan(lambda) * Math.Sin(phi)) * 180d / Math.PI;
}
public override Matrix RelativeTransform(double latitude, double longitude)
{
var k = ScaleFactor;
var gamma = 0d; // γ
if (latitude > -90d && latitude < 90d)
{
var phi = latitude * Math.PI / 180d;
var sinPhi = Math.Sin(phi);
var cosPhi = Math.Cos(phi);
var tanPhi = sinPhi / cosPhi;
var lambda = (longitude - CentralMeridian) * Math.PI / 180d;
var e2 = (2d - Flattening) * Flattening;
var e_2 = e2 / (1d - e2); // (8-12)
var T = tanPhi * tanPhi; // (8-13)
var C = e_2 * cosPhi * cosPhi; // (8-14)
var A = lambda * cosPhi; // (8-15)
var A2 = A * A;
var A4 = A2 * A2;
var A6 = A2 * A4;
k *= 1d + (1d + C) * A2 / 2d +
(5d - 4d * T + 42d * C + 13d * C * C - 28d * e_2) * A4 / 24d +
(61d - 148d * T + 16 * T * T) * A6 / 720d; // (8-11)
gamma = Math.Atan(Math.Tan(lambda) * sinPhi) * 180d / Math.PI;
}
var transform = new Matrix(k, 0d, 0d, k, 0d, 0d);
transform.Rotate(-gamma);
return transform;
}
public override Point LocationToMap(double latitude, double longitude)
{
var phi = latitude * Math.PI / 180d;
var M = MeridianDistance(phi);
var M0 = MeridianDistance(LatitudeOfOrigin * Math.PI / 180d);
double x, y;
if (latitude > -90d && latitude < 90d)
{
var sinPhi = Math.Sin(phi);
var cosPhi = Math.Cos(phi);
var tanPhi = sinPhi / cosPhi;
var e2 = (2d - Flattening) * Flattening;
var e_2 = e2 / (1d - e2); // (8-12)
var N = EquatorialRadius / Math.Sqrt(1d - e2 * sinPhi * sinPhi); // (4-20)
var T = tanPhi * tanPhi; // (8-13)
var C = e_2 * cosPhi * cosPhi; // (8-14)
var A = (longitude - CentralMeridian) * Math.PI / 180d * cosPhi; // (8-15)
var A2 = A * A;
var A3 = A * A2;
var A4 = A * A3;
var A5 = A * A4;
var A6 = A * A5;
x = ScaleFactor * N *
(A + (1d - T + C) * A3 / 6d + (5d - 18d * T + T * T + 72d * C - 58d * e_2) * A5 / 120d); // (8-9)
y = ScaleFactor * (M - M0 + N * tanPhi * (A2 / 2d + (5d - T + 9d * C + 4d * C * C) * A4 / 24d +
(61d - 58d * T + T * T + 600d * C - 330d * e_2) * A6 / 720d)); // (8-10)
}
else
{
x = 0d;
y = ScaleFactor * (M - M0);
}
return new Point(x + FalseEasting, y + FalseNorthing);
}
public override Location MapToLocation(double x, double y)
{
var e2 = (2d - Flattening) * Flattening; var e2 = (2d - Flattening) * Flattening;
var e4 = e2 * e2;
var e6 = e2 * e4;
var s = Math.Sqrt(1d - e2);
var e1 = (1d - s) / (1d + s); // (3-24)
var e12 = e1 * e1;
var e13 = e1 * e12;
var e14 = e1 * e13;
var M0 = MeridianDistance(LatitudeOfOrigin * Math.PI / 180d);
var M = M0 + (y - FalseNorthing) / ScaleFactor; // (8-20)
var mu = M / (EquatorialRadius * (1d - e2 / 4d - e4 * 3d / 64d - e6 * 5d / 256d)); // (7-19)
var phi0 = mu +
(e1 * 3d / 2d - e13 * 27d / 32d) * Math.Sin(2d * mu) +
(e12 * 21d / 16d - e14 * 55d / 32d) * Math.Sin(4d * mu) +
e13 * 151d / 96d * Math.Sin(6d * mu) +
e14 * 1097d / 512d * Math.Sin(8d * mu); // (3-26)
var sinPhi0 = Math.Sin(phi0);
var cosPhi0 = Math.Cos(phi0);
var tanPhi0 = sinPhi0 / cosPhi0;
var e_2 = e2 / (1d - e2); // (8-12) var e_2 = e2 / (1d - e2); // (8-12)
var C1 = e_2 * cosPhi0 * cosPhi0; // (8-21) var T = tanPhi * tanPhi; // (8-13)
var T1 = sinPhi0 * sinPhi0 / (cosPhi0 * cosPhi0); // (8-22) var C = e_2 * cosPhi * cosPhi; // (8-14)
s = Math.Sqrt(1d - e2 * sinPhi0 * sinPhi0); var A = lambda * cosPhi; // (8-15)
var N1 = EquatorialRadius / s; // (8-23) var A2 = A * A;
var R1 = EquatorialRadius * (1d - e2) / (s * s * s); // (8-24) var A4 = A2 * A2;
var D = (x - FalseEasting) / (N1 * ScaleFactor); // (8-25) var A6 = A2 * A4;
var D2 = D * D;
var D3 = D * D2;
var D4 = D * D3;
var D5 = D * D4;
var D6 = D * D5;
var phi = phi0 - N1 * tanPhi0 / R1 * (D2 / 2d - (5d + 3d * T1 + 10d * C1 - 4d * C1 * C1 - 9d * e_2) * D4 / 24d + k *= 1d + (1d + C) * A2 / 2d +
(61d + 90d * T1 + 45d * T1 * T1 + 298 * C1 - 3d * C1 * C1 - 252d * e_2) * D6 / 720d); // (8-17) (5d - 4d * T + 42d * C + 13d * C * C - 28d * e_2) * A4 / 24d +
(61d - 148d * T + 16 * T * T) * A6 / 720d; // (8-11)
var lambda = (D - (1d + 2d * T1 + C1) * D3 / 6d + gamma = Math.Atan(Math.Tan(lambda) * sinPhi) * 180d / Math.PI;
(5d - 2d * C1 - 3d * C1 * C1 + 28d * T1 + 24d * T1 * T1 + 8d * e_2) * D5 / 120d) / cosPhi0; // (8-18)
return new Location(
phi * 180d / Math.PI,
lambda * 180d / Math.PI + CentralMeridian);
} }
private double MeridianDistance(double phi) var transform = new Matrix(k, 0d, 0d, k, 0d, 0d);
transform.Rotate(-gamma);
return transform;
}
public override Point LocationToMap(double latitude, double longitude)
{
var phi = latitude * Math.PI / 180d;
var M = MeridianDistance(phi);
var M0 = MeridianDistance(LatitudeOfOrigin * Math.PI / 180d);
double x, y;
if (latitude > -90d && latitude < 90d)
{ {
var e2 = (2d - Flattening) * Flattening; var sinPhi = Math.Sin(phi);
var e4 = e2 * e2; var cosPhi = Math.Cos(phi);
var e6 = e2 * e4; var tanPhi = sinPhi / cosPhi;
return EquatorialRadius * ( var e2 = (2d - Flattening) * Flattening;
(1d - e2 / 4d - e4 * 3d / 64d - e6 * 5d / 256d) * phi - var e_2 = e2 / (1d - e2); // (8-12)
(e2 * 3d / 8d + e4 * 3d / 32d + e6 * 45d / 1024d) * Math.Sin(2d * phi) + var N = EquatorialRadius / Math.Sqrt(1d - e2 * sinPhi * sinPhi); // (4-20)
(e4 * 15d / 256d + e6 * 45d / 1024d) * Math.Sin(4d * phi) - var T = tanPhi * tanPhi; // (8-13)
e6 * 35d / 3072d * Math.Sin(6d * phi)); // (3-21) var C = e_2 * cosPhi * cosPhi; // (8-14)
var A = (longitude - CentralMeridian) * Math.PI / 180d * cosPhi; // (8-15)
var A2 = A * A;
var A3 = A * A2;
var A4 = A * A3;
var A5 = A * A4;
var A6 = A * A5;
x = ScaleFactor * N *
(A + (1d - T + C) * A3 / 6d + (5d - 18d * T + T * T + 72d * C - 58d * e_2) * A5 / 120d); // (8-9)
y = ScaleFactor * (M - M0 + N * tanPhi * (A2 / 2d + (5d - T + 9d * C + 4d * C * C) * A4 / 24d +
(61d - 58d * T + T * T + 600d * C - 330d * e_2) * A6 / 720d)); // (8-10)
} }
else
{
x = 0d;
y = ScaleFactor * (M - M0);
}
return new Point(x + FalseEasting, y + FalseNorthing);
}
public override Location MapToLocation(double x, double y)
{
var e2 = (2d - Flattening) * Flattening;
var e4 = e2 * e2;
var e6 = e2 * e4;
var s = Math.Sqrt(1d - e2);
var e1 = (1d - s) / (1d + s); // (3-24)
var e12 = e1 * e1;
var e13 = e1 * e12;
var e14 = e1 * e13;
var M0 = MeridianDistance(LatitudeOfOrigin * Math.PI / 180d);
var M = M0 + (y - FalseNorthing) / ScaleFactor; // (8-20)
var mu = M / (EquatorialRadius * (1d - e2 / 4d - e4 * 3d / 64d - e6 * 5d / 256d)); // (7-19)
var phi0 = mu +
(e1 * 3d / 2d - e13 * 27d / 32d) * Math.Sin(2d * mu) +
(e12 * 21d / 16d - e14 * 55d / 32d) * Math.Sin(4d * mu) +
e13 * 151d / 96d * Math.Sin(6d * mu) +
e14 * 1097d / 512d * Math.Sin(8d * mu); // (3-26)
var sinPhi0 = Math.Sin(phi0);
var cosPhi0 = Math.Cos(phi0);
var tanPhi0 = sinPhi0 / cosPhi0;
var e_2 = e2 / (1d - e2); // (8-12)
var C1 = e_2 * cosPhi0 * cosPhi0; // (8-21)
var T1 = sinPhi0 * sinPhi0 / (cosPhi0 * cosPhi0); // (8-22)
s = Math.Sqrt(1d - e2 * sinPhi0 * sinPhi0);
var N1 = EquatorialRadius / s; // (8-23)
var R1 = EquatorialRadius * (1d - e2) / (s * s * s); // (8-24)
var D = (x - FalseEasting) / (N1 * ScaleFactor); // (8-25)
var D2 = D * D;
var D3 = D * D2;
var D4 = D * D3;
var D5 = D * D4;
var D6 = D * D5;
var phi = phi0 - N1 * tanPhi0 / R1 * (D2 / 2d - (5d + 3d * T1 + 10d * C1 - 4d * C1 * C1 - 9d * e_2) * D4 / 24d +
(61d + 90d * T1 + 45d * T1 * T1 + 298 * C1 - 3d * C1 * C1 - 252d * e_2) * D6 / 720d); // (8-17)
var lambda = (D - (1d + 2d * T1 + C1) * D3 / 6d +
(5d - 2d * C1 - 3d * C1 * C1 + 28d * T1 + 24d * T1 * T1 + 8d * e_2) * D5 / 120d) / cosPhi0; // (8-18)
return new Location(
phi * 180d / Math.PI,
lambda * 180d / Math.PI + CentralMeridian);
}
private double MeridianDistance(double phi)
{
var e2 = (2d - Flattening) * Flattening;
var e4 = e2 * e2;
var e6 = e2 * e4;
return EquatorialRadius * (
(1d - e2 / 4d - e4 * 3d / 64d - e6 * 5d / 256d) * phi -
(e2 * 3d / 8d + e4 * 3d / 32d + e6 * 45d / 1024d) * Math.Sin(2d * phi) +
(e4 * 15d / 256d + e6 * 45d / 1024d) * Math.Sin(4d * phi) -
e6 * 35d / 3072d * Math.Sin(6d * phi)); // (3-21)
} }
} }

View file

@ -16,120 +16,119 @@ using ConverterCulture = System.String;
using ConverterCulture = System.Globalization.CultureInfo; using ConverterCulture = System.Globalization.CultureInfo;
#endif #endif
namespace MapControl namespace MapControl;
public partial class LocationConverter : TypeConverter, IValueConverter
{ {
public partial class LocationConverter : TypeConverter, IValueConverter public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{ {
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) return sourceType == typeof(string);
{
return sourceType == typeof(string);
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
return Location.Parse(value.ToString());
}
public object Convert(object value, Type targetType, object parameter, ConverterCulture culture)
{
return ConvertFrom(value.ToString());
}
public object ConvertBack(object value, Type targetType, object parameter, ConverterCulture culture)
{
return value.ToString();
}
} }
public partial class LocationCollectionConverter : TypeConverter, IValueConverter public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{ {
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) return Location.Parse(value.ToString());
{
return sourceType == typeof(string);
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
return LocationCollection.Parse(value.ToString());
}
public object Convert(object value, Type targetType, object parameter, ConverterCulture culture)
{
return ConvertFrom(value.ToString());
}
public object ConvertBack(object value, Type targetType, object parameter, ConverterCulture culture)
{
return value.ToString();
}
} }
public partial class BoundingBoxConverter : TypeConverter, IValueConverter public object Convert(object value, Type targetType, object parameter, ConverterCulture culture)
{ {
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) return ConvertFrom(value.ToString());
{
return sourceType == typeof(string);
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
return BoundingBox.Parse(value.ToString());
}
public object Convert(object value, Type targetType, object parameter, ConverterCulture culture)
{
return ConvertFrom(value.ToString());
}
public object ConvertBack(object value, Type targetType, object parameter, ConverterCulture culture)
{
return value.ToString();
}
} }
public partial class TileSourceConverter : TypeConverter, IValueConverter public object ConvertBack(object value, Type targetType, object parameter, ConverterCulture culture)
{ {
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) return value.ToString();
{ }
return sourceType == typeof(string); }
}
public partial class LocationCollectionConverter : TypeConverter, IValueConverter
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) {
{ public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
return TileSource.Parse(value.ToString()); {
} return sourceType == typeof(string);
}
public object Convert(object value, Type targetType, object parameter, ConverterCulture culture)
{ public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
return ConvertFrom(value.ToString()); {
} return LocationCollection.Parse(value.ToString());
}
public object ConvertBack(object value, Type targetType, object parameter, ConverterCulture culture)
{ public object Convert(object value, Type targetType, object parameter, ConverterCulture culture)
return value.ToString(); {
} return ConvertFrom(value.ToString());
} }
public partial class MapProjectionConverter : TypeConverter, IValueConverter public object ConvertBack(object value, Type targetType, object parameter, ConverterCulture culture)
{ {
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) return value.ToString();
{ }
return sourceType == typeof(string); }
}
public partial class BoundingBoxConverter : TypeConverter, IValueConverter
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) {
{ public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
return MapProjection.Parse(value.ToString()); {
} return sourceType == typeof(string);
}
public object Convert(object value, Type targetType, object parameter, ConverterCulture culture)
{ public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
return ConvertFrom(value.ToString()); {
} return BoundingBox.Parse(value.ToString());
}
public object ConvertBack(object value, Type targetType, object parameter, ConverterCulture culture)
{ public object Convert(object value, Type targetType, object parameter, ConverterCulture culture)
return value.ToString(); {
} return ConvertFrom(value.ToString());
}
public object ConvertBack(object value, Type targetType, object parameter, ConverterCulture culture)
{
return value.ToString();
}
}
public partial class TileSourceConverter : TypeConverter, IValueConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return sourceType == typeof(string);
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
return TileSource.Parse(value.ToString());
}
public object Convert(object value, Type targetType, object parameter, ConverterCulture culture)
{
return ConvertFrom(value.ToString());
}
public object ConvertBack(object value, Type targetType, object parameter, ConverterCulture culture)
{
return value.ToString();
}
}
public partial class MapProjectionConverter : TypeConverter, IValueConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return sourceType == typeof(string);
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
return MapProjection.Parse(value.ToString());
}
public object Convert(object value, Type targetType, object parameter, ConverterCulture culture)
{
return ConvertFrom(value.ToString());
}
public object ConvertBack(object value, Type targetType, object parameter, ConverterCulture culture)
{
return value.ToString();
} }
} }

View file

@ -14,30 +14,29 @@ using Avalonia;
using Avalonia.Media; using Avalonia.Media;
#endif #endif
namespace MapControl namespace MapControl;
{
public static class UIElementExtension
{
public static void SetRenderTransform(this UIElement element, Transform transform, bool center = false)
{
element.RenderTransform = transform;
#if AVALONIA
element.RenderTransformOrigin = center ? RelativePoint.Center : RelativePoint.TopLeft;
#else
if (center)
{
element.RenderTransformOrigin = new Point(0.5, 0.5);
}
#endif
}
public static void SetVisible(this UIElement element, bool visible) public static class UIElementExtension
{ {
public static void SetRenderTransform(this UIElement element, Transform transform, bool center = false)
{
element.RenderTransform = transform;
#if AVALONIA #if AVALONIA
element.IsVisible = visible; element.RenderTransformOrigin = center ? RelativePoint.Center : RelativePoint.TopLeft;
#else #else
element.Visibility = visible ? Visibility.Visible : Visibility.Collapsed; if (center)
#endif {
element.RenderTransformOrigin = new Point(0.5, 0.5);
} }
#endif
}
public static void SetVisible(this UIElement element, bool visible)
{
#if AVALONIA
element.IsVisible = visible;
#else
element.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
#endif
} }
} }

View file

@ -8,21 +8,20 @@ using Microsoft.UI.Xaml;
using Avalonia.Threading; using Avalonia.Threading;
#endif #endif
namespace MapControl namespace MapControl;
{
internal class UpdateTimer : DispatcherTimer
{
public void Run(bool restart = false)
{
if (restart)
{
Stop();
}
if (!IsEnabled) internal class UpdateTimer : DispatcherTimer
{ {
Start(); public void Run(bool restart = false)
} {
if (restart)
{
Stop();
}
if (!IsEnabled)
{
Start();
} }
} }
} }

View file

@ -1,54 +1,53 @@
using System; using System;
namespace MapControl namespace MapControl;
public class UriTileSource : TileSource
{ {
public class UriTileSource : TileSource private string uriFormat;
public string UriTemplate
{ {
private string uriFormat; get;
set
public string UriTemplate
{ {
get; field = value;
set uriFormat = field
.Replace("{z}", "{0}")
.Replace("{x}", "{1}")
.Replace("{y}", "{2}")
.Replace("{s}", "{3}");
if (Subdomains == null && field.Contains("{s}"))
{ {
field = value; Subdomains = ["a", "b", "c"]; // default OpenStreetMap subdomains
uriFormat = field
.Replace("{z}", "{0}")
.Replace("{x}", "{1}")
.Replace("{y}", "{2}")
.Replace("{s}", "{3}");
if (Subdomains == null && field.Contains("{s}"))
{
Subdomains = ["a", "b", "c"]; // default OpenStreetMap subdomains
}
} }
} }
public string[] Subdomains { get; set; }
public override Uri GetUri(int zoomLevel, int column, int row)
{
Uri uri = null;
if (uriFormat != null)
{
var uriString = Subdomains?.Length > 0
? string.Format(uriFormat, zoomLevel, column, row, Subdomains[(column + row) % Subdomains.Length])
: string.Format(uriFormat, zoomLevel, column, row);
uri = new Uri(uriString, UriKind.RelativeOrAbsolute);
}
return uri;
}
} }
public class TmsTileSource : UriTileSource public string[] Subdomains { get; set; }
public override Uri GetUri(int zoomLevel, int column, int row)
{ {
public override Uri GetUri(int zoomLevel, int column, int row) Uri uri = null;
if (uriFormat != null)
{ {
return base.GetUri(zoomLevel, column, (1 << zoomLevel) - 1 - row); var uriString = Subdomains?.Length > 0
? string.Format(uriFormat, zoomLevel, column, row, Subdomains[(column + row) % Subdomains.Length])
: string.Format(uriFormat, zoomLevel, column, row);
uri = new Uri(uriString, UriKind.RelativeOrAbsolute);
} }
return uri;
}
}
public class TmsTileSource : UriTileSource
{
public override Uri GetUri(int zoomLevel, int column, int row)
{
return base.GetUri(zoomLevel, column, (1 << zoomLevel) - 1 - row);
} }
} }

View file

@ -1,83 +1,82 @@
using System; using System;
namespace MapControl namespace MapControl;
public class UtmProjection : TransverseMercatorProjection
{ {
public class UtmProjection : TransverseMercatorProjection public UtmProjection(string crsId, double equatorialRadius, double flattening, int zone, bool north = true)
: base(equatorialRadius, flattening)
{ {
public UtmProjection(string crsId, double equatorialRadius, double flattening, int zone, bool north = true) CrsId = crsId;
: base(equatorialRadius, flattening) ScaleFactor = 0.9996;
{ CentralMeridian = zone * 6 - 183;
CrsId = crsId; FalseEasting = 5e5;
ScaleFactor = 0.9996; FalseNorthing = north ? 0d : 1e7;
CentralMeridian = zone * 6 - 183; Zone = zone;
FalseEasting = 5e5;
FalseNorthing = north ? 0d : 1e7;
Zone = zone;
}
public int Zone { get; }
} }
/// <summary> public int Zone { get; }
/// WGS84 Universal Transverse Mercator Projection - }
/// EPSG:32601 to EPSG:32660 and EPSG:32701 to EPSG:32760.
/// </summary> /// <summary>
public class Wgs84UtmProjection : UtmProjection /// WGS84 Universal Transverse Mercator Projection -
/// EPSG:32601 to EPSG:32660 and EPSG:32701 to EPSG:32760.
/// </summary>
public class Wgs84UtmProjection : UtmProjection
{
public const int FirstZone = 1;
public const int LastZone = 60;
public const int FirstZoneNorthEpsgCode = 32600 + FirstZone;
public const int LastZoneNorthEpsgCode = 32600 + LastZone;
public const int FirstZoneSouthEpsgCode = 32700 + FirstZone;
public const int LastZoneSouthEpsgCode = 32700 + LastZone;
public Wgs84UtmProjection(int zone, bool north)
: base($"EPSG:{(north ? 32600 : 32700) + zone}", Wgs84EquatorialRadius, Wgs84Flattening, zone, north)
{ {
public const int FirstZone = 1; if (zone < FirstZone || zone > LastZone)
public const int LastZone = 60;
public const int FirstZoneNorthEpsgCode = 32600 + FirstZone;
public const int LastZoneNorthEpsgCode = 32600 + LastZone;
public const int FirstZoneSouthEpsgCode = 32700 + FirstZone;
public const int LastZoneSouthEpsgCode = 32700 + LastZone;
public Wgs84UtmProjection(int zone, bool north)
: base($"EPSG:{(north ? 32600 : 32700) + zone}", Wgs84EquatorialRadius, Wgs84Flattening, zone, north)
{ {
if (zone < FirstZone || zone > LastZone) throw new ArgumentException($"Invalid WGS84 UTM zone {zone}.", nameof(zone));
{ }
throw new ArgumentException($"Invalid WGS84 UTM zone {zone}.", nameof(zone)); }
} }
}
} /// <summary>
/// ETRS89 Universal Transverse Mercator Projection - EPSG:25828 to EPSG:25838.
/// <summary> /// </summary>
/// ETRS89 Universal Transverse Mercator Projection - EPSG:25828 to EPSG:25838. public class Etrs89UtmProjection : UtmProjection
/// </summary> {
public class Etrs89UtmProjection : UtmProjection public const int FirstZone = 28;
{ public const int LastZone = 38;
public const int FirstZone = 28; public const int FirstZoneEpsgCode = 25800 + FirstZone;
public const int LastZone = 38; public const int LastZoneEpsgCode = 25800 + LastZone;
public const int FirstZoneEpsgCode = 25800 + FirstZone;
public const int LastZoneEpsgCode = 25800 + LastZone; public Etrs89UtmProjection(int zone)
: base($"EPSG:{25800 + zone}", 6378137d, 1d / 298.257222101, zone) // GRS 1980
public Etrs89UtmProjection(int zone) {
: base($"EPSG:{25800 + zone}", 6378137d, 1d / 298.257222101, zone) // GRS 1980 if (zone < FirstZone || zone > LastZone)
{ {
if (zone < FirstZone || zone > LastZone) throw new ArgumentException($"Invalid ETRS89 UTM zone {zone}.", nameof(zone));
{ }
throw new ArgumentException($"Invalid ETRS89 UTM zone {zone}.", nameof(zone)); }
} }
}
} /// <summary>
/// NAD83 Universal Transverse Mercator Projection - EPSG:26901 to EPSG:26923.
/// <summary> /// </summary>
/// NAD83 Universal Transverse Mercator Projection - EPSG:26901 to EPSG:26923. public class Nad83UtmProjection : UtmProjection
/// </summary> {
public class Nad83UtmProjection : UtmProjection public const int FirstZone = 1;
{ public const int LastZone = 23;
public const int FirstZone = 1; public const int FirstZoneEpsgCode = 26900 + FirstZone;
public const int LastZone = 23; public const int LastZoneEpsgCode = 26900 + LastZone;
public const int FirstZoneEpsgCode = 26900 + FirstZone;
public const int LastZoneEpsgCode = 26900 + LastZone; public Nad83UtmProjection(int zone)
: base($"EPSG:{26900 + zone}", 6378137d, 1d / 298.257222101, zone) // GRS 1980
public Nad83UtmProjection(int zone) {
: base($"EPSG:{26900 + zone}", 6378137d, 1d / 298.257222101, zone) // GRS 1980 if (zone < FirstZone || zone > LastZone)
{ {
if (zone < FirstZone || zone > LastZone) throw new ArgumentException($"Invalid NAD83 UTM zone {zone}.", nameof(zone));
{
throw new ArgumentException($"Invalid NAD83 UTM zone {zone}.", nameof(zone));
}
} }
} }
} }

View file

@ -6,152 +6,151 @@ using System.Windows.Media;
using Avalonia; using Avalonia;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Defines the transformation between projected map coordinates in meters
/// and view coordinates in pixels.
/// </summary>
public class ViewTransform
{ {
/// <summary> /// <summary>
/// Defines the transformation between projected map coordinates in meters /// Gets the scaling factor from projected map coordinates to view coordinates,
/// and view coordinates in pixels. /// as pixels per meter.
/// </summary> /// </summary>
public class ViewTransform public double Scale { get; private set; }
/// <summary>
/// Gets the rotation angle of the transform matrix.
/// </summary>
public double Rotation { get; private set; }
/// <summary>
/// Gets the transform matrix from projected map coordinates to view coordinates.
/// </summary>
public Matrix MapToViewMatrix { get; private set; }
/// <summary>
/// Gets the transform matrix from view coordinates to projected map coordinates.
/// </summary>
public Matrix ViewToMapMatrix { get; private set; }
/// <summary>
/// Transforms a Point in projected map coordinates to a Point in view coordinates.
/// </summary>
public Point MapToView(Point point) => MapToViewMatrix.Transform(point);
/// <summary>
/// Transforms a Point in view coordinates to a Point in projected map coordinates.
/// </summary>
public Point ViewToMap(Point point) => ViewToMapMatrix.Transform(point);
/// <summary>
/// Gets an axis-aligned bounding box in projected map coordinates that contains
/// a rectangle in view coordinates.
/// </summary>
public Rect ViewToMapBounds(Rect rect) => TransformBounds(ViewToMapMatrix, rect.X, rect.Y, rect.Width, rect.Height);
/// <summary>
/// Initializes a ViewTransform from a map center point in projected coordinates,
/// a view conter point, a scaling factor from projected coordinates to view coordinates
/// and a rotation angle in degrees.
/// </summary>
public void SetTransform(Point mapCenter, Point viewCenter, double scale, double rotation)
{ {
/// <summary> Scale = scale;
/// Gets the scaling factor from projected map coordinates to view coordinates, Rotation = (rotation % 360d + 540d) % 360d - 180d;
/// as pixels per meter.
/// </summary>
public double Scale { get; private set; }
/// <summary> var transform = new Matrix(scale, 0d, 0d, -scale, -scale * mapCenter.X, scale * mapCenter.Y);
/// Gets the rotation angle of the transform matrix. transform.Rotate(Rotation);
/// </summary> transform.Translate(viewCenter.X, viewCenter.Y);
public double Rotation { get; private set; } MapToViewMatrix = transform;
/// <summary> transform.Invert();
/// Gets the transform matrix from projected map coordinates to view coordinates. ViewToMapMatrix = transform;
/// </summary> }
public Matrix MapToViewMatrix { get; private set; }
/// <summary> /// <summary>
/// Gets the transform matrix from view coordinates to projected map coordinates. /// Gets the transform Matrix for the RenderTranform of a MapTileLayer or WmtsTileMatrixLayer.
/// </summary> /// </summary>
public Matrix ViewToMapMatrix { get; private set; } public Matrix GetTileLayerTransform(double tileMatrixScale, Point tileMatrixTopLeft, Point tileMatrixOrigin)
{
var scale = Scale / tileMatrixScale;
var transform = new Matrix(scale, 0d, 0d, scale, 0d, 0d);
transform.Rotate(Rotation);
/// <summary> // Tile matrix origin in map coordinates.
/// Transforms a Point in projected map coordinates to a Point in view coordinates. //
/// </summary> var mapOrigin = new Point(
public Point MapToView(Point point) => MapToViewMatrix.Transform(point); tileMatrixTopLeft.X + tileMatrixOrigin.X / tileMatrixScale,
tileMatrixTopLeft.Y - tileMatrixOrigin.Y / tileMatrixScale);
/// <summary> // Tile matrix origin in view coordinates.
/// Transforms a Point in view coordinates to a Point in projected map coordinates. //
/// </summary> var viewOrigin = MapToViewMatrix.Transform(mapOrigin);
public Point ViewToMap(Point point) => ViewToMapMatrix.Transform(point); transform.Translate(viewOrigin.X, viewOrigin.Y);
/// <summary> return transform;
/// Gets an axis-aligned bounding box in projected map coordinates that contains }
/// a rectangle in view coordinates.
/// </summary>
public Rect ViewToMapBounds(Rect rect) => TransformBounds(ViewToMapMatrix, rect.X, rect.Y, rect.Width, rect.Height);
/// <summary> /// <summary>
/// Initializes a ViewTransform from a map center point in projected coordinates, /// Gets the pixel bounds of a tile matrix.
/// a view conter point, a scaling factor from projected coordinates to view coordinates /// </summary>
/// and a rotation angle in degrees. public Rect GetTileMatrixBounds(double tileMatrixScale, Point tileMatrixTopLeft, double viewWidth, double viewHeight)
/// </summary> {
public void SetTransform(Point mapCenter, Point viewCenter, double scale, double rotation) var scale = tileMatrixScale / Scale;
var transform = new Matrix(scale, 0d, 0d, scale, 0d, 0d);
transform.Rotate(-Rotation);
// View origin in map coordinates.
//
var origin = ViewToMapMatrix.Transform(new Point());
// Translation from origin to tile matrix origin in pixels.
//
transform.Translate(
tileMatrixScale * (origin.X - tileMatrixTopLeft.X),
tileMatrixScale * (tileMatrixTopLeft.Y - origin.Y));
// Transform view bounds to tile pixel bounds.
//
return TransformBounds(transform, 0d, 0d, viewWidth, viewHeight);
}
public static Rect TransformBounds(Matrix transform, double x, double y, double width, double height)
{
if (transform.M12 == 0d && transform.M21 == 0d)
{ {
Scale = scale; x = x * transform.M11 + transform.OffsetX;
Rotation = (rotation % 360d + 540d) % 360d - 180d; y = y * transform.M22 + transform.OffsetY;
width *= transform.M11;
height *= transform.M22;
var transform = new Matrix(scale, 0d, 0d, -scale, -scale * mapCenter.X, scale * mapCenter.Y); if (width < 0d)
transform.Rotate(Rotation);
transform.Translate(viewCenter.X, viewCenter.Y);
MapToViewMatrix = transform;
transform.Invert();
ViewToMapMatrix = transform;
}
/// <summary>
/// Gets the transform Matrix for the RenderTranform of a MapTileLayer or WmtsTileMatrixLayer.
/// </summary>
public Matrix GetTileLayerTransform(double tileMatrixScale, Point tileMatrixTopLeft, Point tileMatrixOrigin)
{
var scale = Scale / tileMatrixScale;
var transform = new Matrix(scale, 0d, 0d, scale, 0d, 0d);
transform.Rotate(Rotation);
// 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 = MapToViewMatrix.Transform(mapOrigin);
transform.Translate(viewOrigin.X, viewOrigin.Y);
return transform;
}
/// <summary>
/// Gets the pixel bounds of a tile matrix.
/// </summary>
public Rect GetTileMatrixBounds(double tileMatrixScale, Point tileMatrixTopLeft, double viewWidth, double viewHeight)
{
var scale = tileMatrixScale / Scale;
var transform = new Matrix(scale, 0d, 0d, scale, 0d, 0d);
transform.Rotate(-Rotation);
// View origin in map coordinates.
//
var origin = ViewToMapMatrix.Transform(new Point());
// Translation from origin to tile matrix origin in pixels.
//
transform.Translate(
tileMatrixScale * (origin.X - tileMatrixTopLeft.X),
tileMatrixScale * (tileMatrixTopLeft.Y - origin.Y));
// Transform view bounds to tile pixel bounds.
//
return TransformBounds(transform, 0d, 0d, viewWidth, viewHeight);
}
public static Rect TransformBounds(Matrix transform, double x, double y, double width, double height)
{
if (transform.M12 == 0d && transform.M21 == 0d)
{ {
x = x * transform.M11 + transform.OffsetX; width = -width;
y = y * transform.M22 + transform.OffsetY; x -= width;
width *= transform.M11;
height *= transform.M22;
if (width < 0d)
{
width = -width;
x -= width;
}
if (height < 0d)
{
height = -height;
y -= height;
}
}
else
{
var p1 = transform.Transform(new Point(x, y));
var p2 = transform.Transform(new Point(x, y + height));
var p3 = transform.Transform(new Point(x + width, y));
var p4 = transform.Transform(new Point(x + width, y + height));
x = Math.Min(p1.X, Math.Min(p2.X, Math.Min(p3.X, p4.X)));
y = Math.Min(p1.Y, Math.Min(p2.Y, Math.Min(p3.Y, p4.Y)));
width = Math.Max(p1.X, Math.Max(p2.X, Math.Max(p3.X, p4.X))) - x;
height = Math.Max(p1.Y, Math.Max(p2.Y, Math.Max(p3.Y, p4.Y))) - y;
} }
return new Rect(x, y, width, height); if (height < 0d)
{
height = -height;
y -= height;
}
} }
else
{
var p1 = transform.Transform(new Point(x, y));
var p2 = transform.Transform(new Point(x, y + height));
var p3 = transform.Transform(new Point(x + width, y));
var p4 = transform.Transform(new Point(x + width, y + height));
x = Math.Min(p1.X, Math.Min(p2.X, Math.Min(p3.X, p4.X)));
y = Math.Min(p1.Y, Math.Min(p2.Y, Math.Min(p3.Y, p4.Y)));
width = Math.Max(p1.X, Math.Max(p2.X, Math.Max(p3.X, p4.X))) - x;
height = Math.Max(p1.Y, Math.Max(p2.Y, Math.Max(p3.Y, p4.Y))) - y;
}
return new Rect(x, y, width, height);
} }
} }

View file

@ -1,20 +1,19 @@
using System; using System;
namespace MapControl namespace MapControl;
{
public class ViewportChangedEventArgs(bool projectionChanged = false, bool transformCenterChanged = false) : EventArgs
{
/// <summary>
/// Indicates that the map projection has changed. Used to control when
/// a MapTileLayer or a MapImageLayer should be updated immediately,
/// or MapPath Data in projected map coordinates should be recalculated.
/// </summary>
public bool ProjectionChanged => projectionChanged;
/// <summary> public class ViewportChangedEventArgs(bool projectionChanged = false, bool transformCenterChanged = false) : EventArgs
/// Indicates that the view transform center has moved across 180° longitude. {
/// Used to control when a MapTileLayer should be updated immediately. /// <summary>
/// </summary> /// Indicates that the map projection has changed. Used to control when
public bool TransformCenterChanged => transformCenterChanged; /// a MapTileLayer or a MapImageLayer should be updated immediately,
} /// or MapPath Data in projected map coordinates should be recalculated.
/// </summary>
public bool ProjectionChanged => projectionChanged;
/// <summary>
/// Indicates that the view transform center has moved across 180° longitude.
/// Used to control when a MapTileLayer should be updated immediately.
/// </summary>
public bool TransformCenterChanged => transformCenterChanged;
} }

View file

@ -6,66 +6,65 @@ using System.Windows.Media;
using Avalonia; using Avalonia;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Spherical Mercator Projection - EPSG:3857.
/// See "Map Projections - A Working Manual" (https://pubs.usgs.gov/publication/pp1395), p.41-44.
/// </summary>
public class WebMercatorProjection : MapProjection
{ {
/// <summary> public const string DefaultCrsId = "EPSG:3857";
/// Spherical Mercator Projection - EPSG:3857.
/// See "Map Projections - A Working Manual" (https://pubs.usgs.gov/publication/pp1395), p.41-44. public WebMercatorProjection() // parameterless constructor for XAML
/// </summary> : this(DefaultCrsId)
public class WebMercatorProjection : MapProjection
{ {
public const string DefaultCrsId = "EPSG:3857"; }
public WebMercatorProjection() // parameterless constructor for XAML public WebMercatorProjection(string crsId)
: this(DefaultCrsId) {
IsNormalCylindrical = true;
CrsId = crsId;
}
public override Matrix RelativeTransform(double latitude, double longitude)
{
var k = 1d / Math.Cos(latitude * Math.PI / 180d); // p.44 (7-3)
return new Matrix(k, 0d, 0d, k, 0d, 0d);
}
public override Point LocationToMap(double latitude, double longitude)
{
return new Point(
EquatorialRadius * Math.PI / 180d * longitude,
EquatorialRadius * Math.PI / 180d * LatitudeToY(latitude));
}
public override Location MapToLocation(double x, double y)
{
return new Location(YToLatitude(
y / EquatorialRadius * 180d / Math.PI),
x / EquatorialRadius * 180d / Math.PI);
}
public static double LatitudeToY(double latitude)
{
if (latitude <= -90d)
{ {
return double.NegativeInfinity;
} }
public WebMercatorProjection(string crsId) if (latitude >= 90d)
{ {
IsNormalCylindrical = true; return double.PositiveInfinity;
CrsId = crsId;
} }
public override Matrix RelativeTransform(double latitude, double longitude) return Math.Log(Math.Tan((latitude + 90d) * Math.PI / 360d)) * 180d / Math.PI;
{ }
var k = 1d / Math.Cos(latitude * Math.PI / 180d); // p.44 (7-3)
return new Matrix(k, 0d, 0d, k, 0d, 0d); public static double YToLatitude(double y)
} {
return 90d - Math.Atan(Math.Exp(-y * Math.PI / 180d)) * 360d / Math.PI;
public override Point LocationToMap(double latitude, double longitude)
{
return new Point(
EquatorialRadius * Math.PI / 180d * longitude,
EquatorialRadius * Math.PI / 180d * LatitudeToY(latitude));
}
public override Location MapToLocation(double x, double y)
{
return new Location(YToLatitude(
y / EquatorialRadius * 180d / Math.PI),
x / EquatorialRadius * 180d / Math.PI);
}
public static double LatitudeToY(double latitude)
{
if (latitude <= -90d)
{
return double.NegativeInfinity;
}
if (latitude >= 90d)
{
return double.PositiveInfinity;
}
return Math.Log(Math.Tan((latitude + 90d) * Math.PI / 360d)) * 180d / Math.PI;
}
public static double YToLatitude(double y)
{
return 90d - Math.Atan(Math.Exp(-y * Math.PI / 180d)) * 360d / Math.PI;
}
} }
} }

View file

@ -20,336 +20,335 @@ using Avalonia.Interactivity;
using ImageSource = Avalonia.Media.IImage; using ImageSource = Avalonia.Media.IImage;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Displays a single map image from a Web Map Service (WMS).
/// </summary>
public partial class WmsImageLayer : MapImageLayer
{ {
private static ILogger Logger => field ??= ImageLoader.LoggerFactory?.CreateLogger(typeof(WmsImageLayer));
public static readonly DependencyProperty ServiceUriProperty =
DependencyPropertyHelper.Register<WmsImageLayer, Uri>(nameof(ServiceUri), null,
async (layer, oldValue, newValue) => await layer.UpdateImageAsync());
public static readonly DependencyProperty RequestStylesProperty =
DependencyPropertyHelper.Register<WmsImageLayer, string>(nameof(RequestStyles), "",
async (layer, oldValue, newValue) => await layer.UpdateImageAsync());
public static readonly DependencyProperty RequestLayersProperty =
DependencyPropertyHelper.Register<WmsImageLayer, string>(nameof(RequestLayers), null,
async (layer, oldValue, newValue) => await layer.UpdateImageAsync());
/// <summary> /// <summary>
/// Displays a single map image from a Web Map Service (WMS). /// The base request URL.
/// </summary> /// </summary>
public partial class WmsImageLayer : MapImageLayer public Uri ServiceUri
{ {
private static ILogger Logger => field ??= ImageLoader.LoggerFactory?.CreateLogger(typeof(WmsImageLayer)); get => (Uri)GetValue(ServiceUriProperty);
set => SetValue(ServiceUriProperty, value);
}
public static readonly DependencyProperty ServiceUriProperty = /// <summary>
DependencyPropertyHelper.Register<WmsImageLayer, Uri>(nameof(ServiceUri), null, /// Comma-separated sequence of requested WMS Styles. Default is an empty string.
async (layer, oldValue, newValue) => await layer.UpdateImageAsync()); /// </summary>
public string RequestStyles
{
get => (string)GetValue(RequestStylesProperty);
set => SetValue(RequestStylesProperty, value);
}
public static readonly DependencyProperty RequestStylesProperty = /// <summary>
DependencyPropertyHelper.Register<WmsImageLayer, string>(nameof(RequestStyles), "", /// Comma-separated sequence of WMS Layer names to be displayed. If not set, the default Layer is displayed.
async (layer, oldValue, newValue) => await layer.UpdateImageAsync()); /// </summary>
public string RequestLayers
{
get => (string)GetValue(RequestLayersProperty);
set => SetValue(RequestLayersProperty, value);
}
public static readonly DependencyProperty RequestLayersProperty = /// <summary>
DependencyPropertyHelper.Register<WmsImageLayer, string>(nameof(RequestLayers), null, /// Gets a collection of all Layer names available in a WMS.
async (layer, oldValue, newValue) => await layer.UpdateImageAsync()); /// </summary>
public IReadOnlyCollection<string> AvailableLayers { get; private set; }
/// <summary> /// <summary>
/// The base request URL. /// Gets a collection of all CRSs supported by a WMS.
/// </summary> /// </summary>
public Uri ServiceUri public IReadOnlyCollection<string> SupportedCrsIds { get; private set; }
private bool HasLayer =>
RequestLayers != null ||
AvailableLayers?.Count > 0 ||
ServiceUri.Query?.IndexOf("LAYERS=", StringComparison.OrdinalIgnoreCase) > 0;
public WmsImageLayer()
{
Loaded += OnLoaded;
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
Loaded -= OnLoaded;
if (ServiceUri != null && !HasLayer)
{ {
get => (Uri)GetValue(ServiceUriProperty); await InitializeAsync();
set => SetValue(ServiceUriProperty, value);
}
/// <summary> if (AvailableLayers != null && AvailableLayers.Count > 0)
/// Comma-separated sequence of requested WMS Styles. Default is an empty string.
/// </summary>
public string RequestStyles
{
get => (string)GetValue(RequestStylesProperty);
set => SetValue(RequestStylesProperty, value);
}
/// <summary>
/// Comma-separated sequence of WMS Layer names to be displayed. If not set, the default Layer is displayed.
/// </summary>
public string RequestLayers
{
get => (string)GetValue(RequestLayersProperty);
set => SetValue(RequestLayersProperty, value);
}
/// <summary>
/// Gets a collection of all Layer names available in a WMS.
/// </summary>
public IReadOnlyCollection<string> AvailableLayers { get; private set; }
/// <summary>
/// Gets a collection of all CRSs supported by a WMS.
/// </summary>
public IReadOnlyCollection<string> SupportedCrsIds { get; private set; }
private bool HasLayer =>
RequestLayers != null ||
AvailableLayers?.Count > 0 ||
ServiceUri.Query?.IndexOf("LAYERS=", StringComparison.OrdinalIgnoreCase) > 0;
public WmsImageLayer()
{
Loaded += OnLoaded;
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
Loaded -= OnLoaded;
if (ServiceUri != null && !HasLayer)
{ {
await InitializeAsync(); await UpdateImageAsync();
if (AvailableLayers != null && AvailableLayers.Count > 0)
{
await UpdateImageAsync();
}
} }
} }
}
/// <summary>
/// Initializes the AvailableLayers and SupportedCrsIds properties. /// <summary>
/// Calling this method is only necessary when no layer name is known in advance. /// Initializes the AvailableLayers and SupportedCrsIds properties.
/// It is called internally in a Loaded event handler when the RequestLayers and AvailableLayers /// Calling this method is only necessary when no layer name is known in advance.
/// properties are null and the ServiceUri.Query part does not contain a LAYERS parameter. /// It is called internally in a Loaded event handler when the RequestLayers and AvailableLayers
/// </summary> /// properties are null and the ServiceUri.Query part does not contain a LAYERS parameter.
public async Task InitializeAsync() /// </summary>
{ public async Task InitializeAsync()
var capabilities = await GetCapabilitiesAsync(); {
var capabilities = await GetCapabilitiesAsync();
if (capabilities != null)
{ if (capabilities != null)
var ns = capabilities.Name.Namespace; {
var capability = capabilities.Element(ns + "Capability"); var ns = capabilities.Name.Namespace;
var capability = capabilities.Element(ns + "Capability");
AvailableLayers = capability
.Descendants(ns + "Layer") AvailableLayers = capability
.Select(e => e.Element(ns + "Name")?.Value) .Descendants(ns + "Layer")
.Where(n => !string.IsNullOrEmpty(n)) .Select(e => e.Element(ns + "Name")?.Value)
.ToList(); .Where(n => !string.IsNullOrEmpty(n))
.ToList();
SupportedCrsIds = capability
.Descendants(ns + "Layer") SupportedCrsIds = capability
.Descendants(ns + "CRS") .Descendants(ns + "Layer")
.Select(e => e.Value) .Descendants(ns + "CRS")
.ToList(); .Select(e => e.Value)
} .ToList();
} }
}
/// <summary>
/// Loads an XElement from the URL returned by GetCapabilitiesRequestUri(). /// <summary>
/// </summary> /// Loads an XElement from the URL returned by GetCapabilitiesRequestUri().
public async Task<XElement> GetCapabilitiesAsync() /// </summary>
{ public async Task<XElement> GetCapabilitiesAsync()
XElement element = null; {
XElement element = null;
if (ServiceUri != null)
{ if (ServiceUri != null)
var uri = GetCapabilitiesRequestUri(); {
var uri = GetCapabilitiesRequestUri();
try
{ try
using var stream = await ImageLoader.HttpClient.GetStreamAsync(uri); {
using var stream = await ImageLoader.HttpClient.GetStreamAsync(uri);
element = await XDocument.LoadRootElementAsync(stream);
} element = await XDocument.LoadRootElementAsync(stream);
catch (Exception ex) }
{ catch (Exception ex)
Logger?.LogError(ex, "Failed reading capabilities from {uri}", uri); {
} Logger?.LogError(ex, "Failed reading capabilities from {uri}", uri);
} }
}
return element;
} return element;
}
/// <summary>
/// Gets a response string from the URL returned by GetFeatureInfoRequestUri(). /// <summary>
/// </summary> /// Gets a response string from the URL returned by GetFeatureInfoRequestUri().
public async Task<string> GetFeatureInfoAsync(Point position, string format = "text/plain") /// </summary>
{ public async Task<string> GetFeatureInfoAsync(Point position, string format = "text/plain")
string response = null; {
string response = null;
if (ServiceUri != null && HasLayer &&
ParentMap != null && ParentMap.InsideViewBounds(position) && if (ServiceUri != null && HasLayer &&
(SupportedCrsIds == null || SupportedCrsIds.Contains(ParentMap.MapProjection.CrsId))) ParentMap != null && ParentMap.InsideViewBounds(position) &&
{ (SupportedCrsIds == null || SupportedCrsIds.Contains(ParentMap.MapProjection.CrsId)))
var uri = GetFeatureInfoRequestUri(position, format); {
var uri = GetFeatureInfoRequestUri(position, format);
try
{ try
response = await ImageLoader.HttpClient.GetStringAsync(uri); {
} response = await ImageLoader.HttpClient.GetStringAsync(uri);
catch (Exception ex) }
{ catch (Exception ex)
Logger?.LogError(ex, "Failed reading feature info from {uri}", uri); {
} Logger?.LogError(ex, "Failed reading feature info from {uri}", uri);
} }
}
return response;
} return response;
}
/// <summary>
/// Loads an ImageSource from the URL returned by GetMapRequestUri(). /// <summary>
/// </summary> /// Loads an ImageSource from the URL returned by GetMapRequestUri().
protected override async Task<ImageSource> GetImageAsync(Rect bbox, IProgress<double> progress) /// </summary>
{ protected override async Task<ImageSource> GetImageAsync(Rect bbox, IProgress<double> progress)
ImageSource image = null; {
ImageSource image = null;
if (ServiceUri != null && HasLayer &&
(SupportedCrsIds == null || SupportedCrsIds.Contains(ParentMap.MapProjection.CrsId))) if (ServiceUri != null && HasLayer &&
{ (SupportedCrsIds == null || SupportedCrsIds.Contains(ParentMap.MapProjection.CrsId)))
var xMin = -180d * MapProjection.Wgs84MeterPerDegree; {
var xMax = 180d * MapProjection.Wgs84MeterPerDegree; var xMin = -180d * MapProjection.Wgs84MeterPerDegree;
var xMax = 180d * MapProjection.Wgs84MeterPerDegree;
if (!ParentMap.MapProjection.IsNormalCylindrical ||
bbox.X >= xMin && bbox.X + bbox.Width <= xMax) if (!ParentMap.MapProjection.IsNormalCylindrical ||
{ bbox.X >= xMin && bbox.X + bbox.Width <= xMax)
var uri = GetMapRequestUri(bbox); {
var uri = GetMapRequestUri(bbox);
image = await ImageLoader.LoadImageAsync(uri, progress);
} image = await ImageLoader.LoadImageAsync(uri, progress);
else }
{ else
var x = bbox.X; {
var x = bbox.X;
if (x < xMin)
{ if (x < xMin)
x += xMax - xMin; {
} x += xMax - xMin;
}
var width1 = Math.Floor(xMax * 1e3) / 1e3 - x; // round down xMax to avoid gap between images
var width2 = bbox.Width - width1; var width1 = Math.Floor(xMax * 1e3) / 1e3 - x; // round down xMax to avoid gap between images
var bbox1 = new Rect(x, bbox.Y, width1, bbox.Height); var width2 = bbox.Width - width1;
var bbox2 = new Rect(xMin, bbox.Y, width2, bbox.Height); var bbox1 = new Rect(x, bbox.Y, width1, bbox.Height);
var uri1 = GetMapRequestUri(bbox1); var bbox2 = new Rect(xMin, bbox.Y, width2, bbox.Height);
var uri2 = GetMapRequestUri(bbox2); var uri1 = GetMapRequestUri(bbox1);
var uri2 = GetMapRequestUri(bbox2);
image = await ImageLoader.LoadMergedImageAsync(uri1, uri2, progress);
} image = await ImageLoader.LoadMergedImageAsync(uri1, uri2, progress);
} }
}
return image;
} return image;
}
/// <summary>
/// Returns a GetCapabilities request URL string. /// <summary>
/// </summary> /// Returns a GetCapabilities request URL string.
protected virtual Uri GetCapabilitiesRequestUri() /// </summary>
{ protected virtual Uri GetCapabilitiesRequestUri()
return GetRequestUri(new Dictionary<string, string> {
{ return GetRequestUri(new Dictionary<string, string>
{ "SERVICE", "WMS" }, {
{ "VERSION", "1.3.0" }, { "SERVICE", "WMS" },
{ "REQUEST", "GetCapabilities" } { "VERSION", "1.3.0" },
}); { "REQUEST", "GetCapabilities" }
} });
}
/// <summary>
/// Returns a GetMap request URL string. /// <summary>
/// </summary> /// Returns a GetMap request URL string.
protected virtual Uri GetMapRequestUri(Rect bbox) /// </summary>
{ protected virtual Uri GetMapRequestUri(Rect bbox)
var width = ParentMap.ViewTransform.Scale * bbox.Width; {
var height = ParentMap.ViewTransform.Scale * bbox.Height; var width = ParentMap.ViewTransform.Scale * bbox.Width;
var height = ParentMap.ViewTransform.Scale * bbox.Height;
return GetRequestUri(new Dictionary<string, string>
{ return GetRequestUri(new Dictionary<string, string>
{ "SERVICE", "WMS" }, {
{ "VERSION", "1.3.0" }, { "SERVICE", "WMS" },
{ "REQUEST", "GetMap" }, { "VERSION", "1.3.0" },
{ "LAYERS", RequestLayers ?? AvailableLayers?.FirstOrDefault() ?? "" }, { "REQUEST", "GetMap" },
{ "STYLES", RequestStyles ?? "" }, { "LAYERS", RequestLayers ?? AvailableLayers?.FirstOrDefault() ?? "" },
{ "FORMAT", "image/png" }, { "STYLES", RequestStyles ?? "" },
{ "CRS", ParentMap.MapProjection.CrsId }, { "FORMAT", "image/png" },
{ "BBOX", GetBboxValue(bbox) }, { "CRS", ParentMap.MapProjection.CrsId },
{ "WIDTH", Math.Ceiling(width).ToString("F0") }, { "BBOX", GetBboxValue(bbox) },
{ "HEIGHT", Math.Ceiling(height).ToString("F0") } { "WIDTH", Math.Ceiling(width).ToString("F0") },
}); { "HEIGHT", Math.Ceiling(height).ToString("F0") }
} });
}
/// <summary>
/// Returns a GetFeatureInfo request URL string. /// <summary>
/// </summary> /// Returns a GetFeatureInfo request URL string.
protected virtual Uri GetFeatureInfoRequestUri(Point position, string format) /// </summary>
{ protected virtual Uri GetFeatureInfoRequestUri(Point position, string format)
var width = ParentMap.ActualWidth; {
var height = ParentMap.ActualHeight; var width = ParentMap.ActualWidth;
var bbox = ParentMap.ViewTransform.ViewToMapBounds(new Rect(0d, 0d, width, height)); var height = ParentMap.ActualHeight;
var bbox = ParentMap.ViewTransform.ViewToMapBounds(new Rect(0d, 0d, width, height));
if (ParentMap.ViewTransform.Rotation != 0d)
{ if (ParentMap.ViewTransform.Rotation != 0d)
width = ParentMap.ViewTransform.Scale * bbox.Width; {
height = ParentMap.ViewTransform.Scale * bbox.Height; width = ParentMap.ViewTransform.Scale * bbox.Width;
height = ParentMap.ViewTransform.Scale * bbox.Height;
var transform = new Matrix(1d, 0d, 0d, 1d, -ParentMap.ActualWidth / 2d, -ParentMap.ActualHeight / 2d);
transform.Rotate(-ParentMap.ViewTransform.Rotation); var transform = new Matrix(1d, 0d, 0d, 1d, -ParentMap.ActualWidth / 2d, -ParentMap.ActualHeight / 2d);
transform.Translate(width / 2d, height / 2d); transform.Rotate(-ParentMap.ViewTransform.Rotation);
transform.Translate(width / 2d, height / 2d);
position = transform.Transform(position);
} position = transform.Transform(position);
}
var queryParameters = new Dictionary<string, string>
{ var queryParameters = new Dictionary<string, string>
{ "SERVICE", "WMS" }, {
{ "VERSION", "1.3.0" }, { "SERVICE", "WMS" },
{ "REQUEST", "GetFeatureInfo" }, { "VERSION", "1.3.0" },
{ "LAYERS", RequestLayers ?? AvailableLayers?.FirstOrDefault() ?? "" }, { "REQUEST", "GetFeatureInfo" },
{ "STYLES", RequestStyles ?? "" }, { "LAYERS", RequestLayers ?? AvailableLayers?.FirstOrDefault() ?? "" },
{ "INFO_FORMAT", format }, { "STYLES", RequestStyles ?? "" },
{ "CRS", ParentMap.MapProjection.CrsId }, { "INFO_FORMAT", format },
{ "BBOX", GetBboxValue(bbox) }, { "CRS", ParentMap.MapProjection.CrsId },
{ "WIDTH", Math.Ceiling(width).ToString("F0") }, { "BBOX", GetBboxValue(bbox) },
{ "HEIGHT", Math.Ceiling(height).ToString("F0") }, { "WIDTH", Math.Ceiling(width).ToString("F0") },
{ "I", position.X.ToString("F0") }, { "HEIGHT", Math.Ceiling(height).ToString("F0") },
{ "J", position.Y.ToString("F0") } { "I", position.X.ToString("F0") },
}; { "J", position.Y.ToString("F0") }
};
// GetRequestUri may modify queryParameters["LAYERS"].
// // GetRequestUri may modify queryParameters["LAYERS"].
var uriBuilder = new UriBuilder(GetRequestUri(queryParameters)); //
var uriBuilder = new UriBuilder(GetRequestUri(queryParameters));
uriBuilder.Query += "&QUERY_LAYERS=" + queryParameters["LAYERS"];
uriBuilder.Query += "&QUERY_LAYERS=" + queryParameters["LAYERS"];
return uriBuilder.Uri;
} return uriBuilder.Uri;
}
protected virtual Uri GetRequestUri(IDictionary<string, string> queryParameters)
{ protected virtual Uri GetRequestUri(IDictionary<string, string> queryParameters)
var query = ServiceUri.Query; {
var query = ServiceUri.Query;
if (!string.IsNullOrEmpty(query))
{ if (!string.IsNullOrEmpty(query))
// Parameters from ServiceUri.Query take higher precedence than queryParameters. {
// // Parameters from ServiceUri.Query take higher precedence than queryParameters.
foreach (var param in query.Substring(1).Split('&')) //
{ foreach (var param in query.Substring(1).Split('&'))
var pair = param.Split('='); {
queryParameters[pair[0]] = pair.Length > 1 ? pair[1] : ""; var pair = param.Split('=');
} queryParameters[pair[0]] = pair.Length > 1 ? pair[1] : "";
} }
}
query = string.Join("&", queryParameters.Select(kv => kv.Key + "=" + kv.Value));
query = string.Join("&", queryParameters.Select(kv => kv.Key + "=" + kv.Value));
return new Uri(ServiceUri.GetLeftPart(UriPartial.Path) + "?" + query);
} return new Uri(ServiceUri.GetLeftPart(UriPartial.Path) + "?" + query);
}
protected virtual string GetBboxValue(Rect bbox)
{ protected virtual string GetBboxValue(Rect bbox)
var crs = ParentMap.MapProjection.CrsId; {
var format = "{0:0.###},{1:0.###},{2:0.###},{3:0.###}"; var crs = ParentMap.MapProjection.CrsId;
var x1 = bbox.X; var format = "{0:0.###},{1:0.###},{2:0.###},{3:0.###}";
var y1 = bbox.Y; var x1 = bbox.X;
var x2 = bbox.X + bbox.Width; var y1 = bbox.Y;
var y2 = bbox.Y + bbox.Height; var x2 = bbox.X + bbox.Width;
var y2 = bbox.Y + bbox.Height;
if (crs == "CRS:84" || crs == "EPSG:4326")
{ if (crs == "CRS:84" || crs == "EPSG:4326")
format = crs == "CRS:84" {
? "{0:0.########},{1:0.########},{2:0.########},{3:0.########}" format = crs == "CRS:84"
: "{1:0.########},{0:0.########},{3:0.########},{2:0.########}"; ? "{0:0.########},{1:0.########},{2:0.########},{3:0.########}"
x1 /= MapProjection.Wgs84MeterPerDegree; : "{1:0.########},{0:0.########},{3:0.########},{2:0.########}";
y1 /= MapProjection.Wgs84MeterPerDegree; x1 /= MapProjection.Wgs84MeterPerDegree;
x2 /= MapProjection.Wgs84MeterPerDegree; y1 /= MapProjection.Wgs84MeterPerDegree;
y2 /= MapProjection.Wgs84MeterPerDegree; x2 /= MapProjection.Wgs84MeterPerDegree;
} y2 /= MapProjection.Wgs84MeterPerDegree;
}
return string.Format(CultureInfo.InvariantCulture, format, x1, y1, x2, y2);
} return string.Format(CultureInfo.InvariantCulture, format, x1, y1, x2, y2);
} }
} }

View file

@ -11,293 +11,292 @@ using System.Windows;
using Avalonia; using Avalonia;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// See https://www.ogc.org/standards/wmts, 07-057r7_Web_Map_Tile_Service_Standard.pdf.
/// </summary>
public class WmtsCapabilities
{ {
/// <summary> private static readonly XNamespace ows = "http://www.opengis.net/ows/1.1";
/// See https://www.ogc.org/standards/wmts, 07-057r7_Web_Map_Tile_Service_Standard.pdf. private static readonly XNamespace wmts = "http://www.opengis.net/wmts/1.0";
/// </summary> private static readonly XNamespace xlink = "http://www.w3.org/1999/xlink";
public class WmtsCapabilities
public string Layer { get; private set; }
public List<WmtsTileMatrixSet> TileMatrixSets { get; private set; }
public static async Task<WmtsCapabilities> ReadCapabilitiesAsync(Uri uri, string layer)
{ {
private static readonly XNamespace ows = "http://www.opengis.net/ows/1.1"; Stream xmlStream;
private static readonly XNamespace wmts = "http://www.opengis.net/wmts/1.0"; string defaultUri = null;
private static readonly XNamespace xlink = "http://www.w3.org/1999/xlink";
public string Layer { get; private set; } if (!uri.IsAbsoluteUri)
public List<WmtsTileMatrixSet> TileMatrixSets { get; private set; }
public static async Task<WmtsCapabilities> ReadCapabilitiesAsync(Uri uri, string layer)
{ {
Stream xmlStream; xmlStream = File.OpenRead(uri.OriginalString);
string defaultUri = null; }
else if (uri.IsFile)
{
xmlStream = File.OpenRead(uri.LocalPath);
}
else if (uri.IsHttp())
{
defaultUri = uri.OriginalString.Split('?')[0];
if (!uri.IsAbsoluteUri) xmlStream = await ImageLoader.HttpClient.GetStreamAsync(uri);
{ }
xmlStream = File.OpenRead(uri.OriginalString); else
} {
else if (uri.IsFile) throw new ArgumentException($"Invalid Capabilities Uri: {uri}");
{
xmlStream = File.OpenRead(uri.LocalPath);
}
else if (uri.IsHttp())
{
defaultUri = uri.OriginalString.Split('?')[0];
xmlStream = await ImageLoader.HttpClient.GetStreamAsync(uri);
}
else
{
throw new ArgumentException($"Invalid Capabilities Uri: {uri}");
}
using var stream = xmlStream;
var element = await XDocument.LoadRootElementAsync(stream);
return ReadCapabilities(element, layer, defaultUri);
} }
public static WmtsCapabilities ReadCapabilities(XElement capabilitiesElement, string layer, string defaultUri) using var stream = xmlStream;
var element = await XDocument.LoadRootElementAsync(stream);
return ReadCapabilities(element, layer, defaultUri);
}
public static WmtsCapabilities ReadCapabilities(XElement capabilitiesElement, string layer, string defaultUri)
{
var contentsElement = capabilitiesElement.Element(wmts + "Contents") ??
throw new ArgumentException("Contents element not found.");
XElement layerElement;
if (!string.IsNullOrEmpty(layer))
{ {
var contentsElement = capabilitiesElement.Element(wmts + "Contents") ?? layerElement = contentsElement
throw new ArgumentException("Contents element not found."); .Elements(wmts + "Layer")
.FirstOrDefault(l => l.Element(ows + "Identifier")?.Value == layer) ??
throw new ArgumentException($"Layer element \"{layer}\" not found.");
}
else
{
layerElement = contentsElement
.Elements(wmts + "Layer")
.FirstOrDefault() ??
throw new ArgumentException("No Layer element found.");
XElement layerElement; layer = layerElement.Element(ows + "Identifier")?.Value ?? "";
if (!string.IsNullOrEmpty(layer))
{
layerElement = contentsElement
.Elements(wmts + "Layer")
.FirstOrDefault(l => l.Element(ows + "Identifier")?.Value == layer) ??
throw new ArgumentException($"Layer element \"{layer}\" not found.");
}
else
{
layerElement = contentsElement
.Elements(wmts + "Layer")
.FirstOrDefault() ??
throw new ArgumentException("No Layer element found.");
layer = layerElement.Element(ows + "Identifier")?.Value ?? "";
}
var styleElement = layerElement
.Elements(wmts + "Style")
.FirstOrDefault(s => s.Attribute("isDefault")?.Value == "true") ??
layerElement
.Elements(wmts + "Style")
.FirstOrDefault();
var style = styleElement?.Element(ows + "Identifier")?.Value ?? "";
var uriTemplate = ReadUriTemplate(capabilitiesElement, layerElement, layer, style, defaultUri);
var tileMatrixSetIds = layerElement
.Elements(wmts + "TileMatrixSetLink")
.Select(l => l.Element(wmts + "TileMatrixSet")?.Value)
.Where(v => !string.IsNullOrEmpty(v));
var tileMatrixSets = new List<WmtsTileMatrixSet>();
foreach (var tileMatrixSetId in tileMatrixSetIds)
{
var tileMatrixSetElement = contentsElement
.Elements(wmts + "TileMatrixSet")
.FirstOrDefault(s => s.Element(ows + "Identifier")?.Value == tileMatrixSetId) ??
throw new ArgumentException($"Linked TileMatrixSet element not found in Layer \"{layer}\".");
tileMatrixSets.Add(ReadTileMatrixSet(tileMatrixSetElement, uriTemplate));
}
return new WmtsCapabilities
{
Layer = layer,
TileMatrixSets = tileMatrixSets
};
} }
public static string ReadUriTemplate(XElement capabilitiesElement, XElement layerElement, string layer, string style, string defaultUri) var styleElement = layerElement
.Elements(wmts + "Style")
.FirstOrDefault(s => s.Attribute("isDefault")?.Value == "true") ??
layerElement
.Elements(wmts + "Style")
.FirstOrDefault();
var style = styleElement?.Element(ows + "Identifier")?.Value ?? "";
var uriTemplate = ReadUriTemplate(capabilitiesElement, layerElement, layer, style, defaultUri);
var tileMatrixSetIds = layerElement
.Elements(wmts + "TileMatrixSetLink")
.Select(l => l.Element(wmts + "TileMatrixSet")?.Value)
.Where(v => !string.IsNullOrEmpty(v));
var tileMatrixSets = new List<WmtsTileMatrixSet>();
foreach (var tileMatrixSetId in tileMatrixSetIds)
{ {
const string formatPng = "image/png"; var tileMatrixSetElement = contentsElement
const string formatJpg = "image/jpeg"; .Elements(wmts + "TileMatrixSet")
string uriTemplate = null; .FirstOrDefault(s => s.Element(ows + "Identifier")?.Value == tileMatrixSetId) ??
throw new ArgumentException($"Linked TileMatrixSet element not found in Layer \"{layer}\".");
var resourceUrls = layerElement tileMatrixSets.Add(ReadTileMatrixSet(tileMatrixSetElement, uriTemplate));
.Elements(wmts + "ResourceURL") }
.Where(r => r.Attribute("resourceType")?.Value == "tile" &&
r.Attribute("format")?.Value != null &&
r.Attribute("template")?.Value != null)
.ToLookup(r => r.Attribute("format").Value,
r => r.Attribute("template").Value);
if (resourceUrls.Count != 0) return new WmtsCapabilities
{
Layer = layer,
TileMatrixSets = tileMatrixSets
};
}
public static string ReadUriTemplate(XElement capabilitiesElement, XElement layerElement, string layer, string style, string defaultUri)
{
const string formatPng = "image/png";
const string formatJpg = "image/jpeg";
string uriTemplate = null;
var resourceUrls = layerElement
.Elements(wmts + "ResourceURL")
.Where(r => r.Attribute("resourceType")?.Value == "tile" &&
r.Attribute("format")?.Value != null &&
r.Attribute("template")?.Value != null)
.ToLookup(r => r.Attribute("format").Value,
r => r.Attribute("template").Value);
if (resourceUrls.Count != 0)
{
var uriTemplates = resourceUrls.Contains(formatPng) ? resourceUrls[formatPng]
: resourceUrls.Contains(formatJpg) ? resourceUrls[formatJpg]
: resourceUrls.First();
uriTemplate = uriTemplates.First().Replace("{Style}", style);
}
else
{
uriTemplate = capabilitiesElement
.Elements(ows + "OperationsMetadata")
.Elements(ows + "Operation")
.Where(o => o.Attribute("name")?.Value == "GetTile")
.Elements(ows + "DCP")
.Elements(ows + "HTTP")
.Elements(ows + "Get")
.Where(g => g.Elements(ows + "Constraint")
.Any(con => con.Attribute("name")?.Value == "GetEncoding" &&
con.Element(ows + "AllowedValues")?.Element(ows + "Value")?.Value == "KVP"))
.Select(g => g.Attribute(xlink + "href")?.Value)
.Where(h => !string.IsNullOrEmpty(h))
.Select(h => h.Split('?')[0])
.FirstOrDefault() ??
defaultUri;
if (uriTemplate != null)
{ {
var uriTemplates = resourceUrls.Contains(formatPng) ? resourceUrls[formatPng] var formats = layerElement
: resourceUrls.Contains(formatJpg) ? resourceUrls[formatJpg] .Elements(wmts + "Format")
: resourceUrls.First(); .Select(f => f.Value);
uriTemplate = uriTemplates.First().Replace("{Style}", style); var format = formats.Contains(formatPng) ? formatPng
} : formats.Contains(formatJpg) ? formatJpg
else : formats.FirstOrDefault();
{
uriTemplate = capabilitiesElement
.Elements(ows + "OperationsMetadata")
.Elements(ows + "Operation")
.Where(o => o.Attribute("name")?.Value == "GetTile")
.Elements(ows + "DCP")
.Elements(ows + "HTTP")
.Elements(ows + "Get")
.Where(g => g.Elements(ows + "Constraint")
.Any(con => con.Attribute("name")?.Value == "GetEncoding" &&
con.Element(ows + "AllowedValues")?.Element(ows + "Value")?.Value == "KVP"))
.Select(g => g.Attribute(xlink + "href")?.Value)
.Where(h => !string.IsNullOrEmpty(h))
.Select(h => h.Split('?')[0])
.FirstOrDefault() ??
defaultUri;
if (uriTemplate != null) if (string.IsNullOrEmpty(format))
{ {
var formats = layerElement format = formatPng;
.Elements(wmts + "Format")
.Select(f => f.Value);
var format = formats.Contains(formatPng) ? formatPng
: formats.Contains(formatJpg) ? formatJpg
: formats.FirstOrDefault();
if (string.IsNullOrEmpty(format))
{
format = formatPng;
}
uriTemplate +=
"?Service=WMTS" +
"&Request=GetTile" +
"&Version=1.0.0" +
"&Layer=" + layer +
"&Style=" + style +
"&Format=" + format +
"&TileMatrixSet={TileMatrixSet}" +
"&TileMatrix={TileMatrix}" +
"&TileRow={TileRow}" +
"&TileCol={TileCol}";
} }
}
if (string.IsNullOrEmpty(uriTemplate)) uriTemplate +=
{ "?Service=WMTS" +
throw new ArgumentException($"No ResourceURL element in Layer \"{layer}\" and no GetTile KVP Operation Metadata found."); "&Request=GetTile" +
"&Version=1.0.0" +
"&Layer=" + layer +
"&Style=" + style +
"&Format=" + format +
"&TileMatrixSet={TileMatrixSet}" +
"&TileMatrix={TileMatrix}" +
"&TileRow={TileRow}" +
"&TileCol={TileCol}";
} }
return uriTemplate;
} }
public static WmtsTileMatrixSet ReadTileMatrixSet(XElement tileMatrixSetElement, string uriTemplate) if (string.IsNullOrEmpty(uriTemplate))
{ {
var identifier = tileMatrixSetElement.Element(ows + "Identifier")?.Value; throw new ArgumentException($"No ResourceURL element in Layer \"{layer}\" and no GetTile KVP Operation Metadata found.");
if (string.IsNullOrEmpty(identifier))
{
throw new ArgumentException("No Identifier element found in TileMatrixSet.");
}
var supportedCrs = tileMatrixSetElement.Element(ows + "SupportedCRS")?.Value;
if (string.IsNullOrEmpty(supportedCrs))
{
throw new ArgumentException($"No SupportedCRS element found in TileMatrixSet \"{identifier}\".");
}
const string urnPrefix = "urn:ogc:def:crs:";
if (supportedCrs.StartsWith(urnPrefix)) // e.g. "urn:ogc:def:crs:EPSG:6.18:3857")
{
var crsComponents = supportedCrs.Substring(urnPrefix.Length).Split(':');
supportedCrs = crsComponents.First() + ":" + crsComponents.Last();
}
var tileMatrixes = new List<WmtsTileMatrix>();
foreach (var tileMatrixElement in tileMatrixSetElement.Elements(wmts + "TileMatrix"))
{
tileMatrixes.Add(ReadTileMatrix(tileMatrixElement, supportedCrs));
}
if (tileMatrixes.Count <= 0)
{
throw new ArgumentException($"No TileMatrix elements found in TileMatrixSet \"{identifier}\".");
}
return new WmtsTileMatrixSet(identifier, supportedCrs, uriTemplate, tileMatrixes);
} }
public static WmtsTileMatrix ReadTileMatrix(XElement tileMatrixElement, string supportedCrs) return uriTemplate;
}
public static WmtsTileMatrixSet ReadTileMatrixSet(XElement tileMatrixSetElement, string uriTemplate)
{
var identifier = tileMatrixSetElement.Element(ows + "Identifier")?.Value;
if (string.IsNullOrEmpty(identifier))
{ {
var identifier = tileMatrixElement.Element(ows + "Identifier")?.Value; throw new ArgumentException("No Identifier element found in TileMatrixSet.");
if (string.IsNullOrEmpty(identifier))
{
throw new ArgumentException("No Identifier element found in TileMatrix.");
}
var valueString = tileMatrixElement.Element(wmts + "ScaleDenominator")?.Value;
if (string.IsNullOrEmpty(valueString) ||
!double.TryParse(valueString, NumberStyles.Float, CultureInfo.InvariantCulture, out double scaleDenominator))
{
throw new ArgumentException($"No ScaleDenominator element found in TileMatrix \"{identifier}\".");
}
valueString = tileMatrixElement.Element(wmts + "TopLeftCorner")?.Value;
string[] topLeftCornerStrings;
if (string.IsNullOrEmpty(valueString) ||
(topLeftCornerStrings = valueString.Split([' '], StringSplitOptions.RemoveEmptyEntries)).Length < 2 ||
!double.TryParse(topLeftCornerStrings[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double left) ||
!double.TryParse(topLeftCornerStrings[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double top))
{
throw new ArgumentException($"No TopLeftCorner element found in TileMatrix \"{identifier}\".");
}
valueString = tileMatrixElement.Element(wmts + "TileWidth")?.Value;
if (string.IsNullOrEmpty(valueString) || !int.TryParse(valueString, out int tileWidth))
{
throw new ArgumentException($"No TileWidth element found in TileMatrix \"{identifier}\".");
}
valueString = tileMatrixElement.Element(wmts + "TileHeight")?.Value;
if (string.IsNullOrEmpty(valueString) || !int.TryParse(valueString, out int tileHeight))
{
throw new ArgumentException($"No TileHeight element found in TileMatrix \"{identifier}\".");
}
valueString = tileMatrixElement.Element(wmts + "MatrixWidth")?.Value;
if (string.IsNullOrEmpty(valueString) || !int.TryParse(valueString, out int matrixWidth))
{
throw new ArgumentException($"No MatrixWidth element found in TileMatrix \"{identifier}\".");
}
valueString = tileMatrixElement.Element(wmts + "MatrixHeight")?.Value;
if (string.IsNullOrEmpty(valueString) || !int.TryParse(valueString, out int matrixHeight))
{
throw new ArgumentException($"No MatrixHeight element found in TileMatrix \"{identifier}\".");
}
var topLeft = supportedCrs == "EPSG:4326"
? new Point(MapProjection.Wgs84MeterPerDegree * top, MapProjection.Wgs84MeterPerDegree * left)
: new Point(left, top);
// See 07-057r7_Web_Map_Tile_Service_Standard.pdf, section 6.1.a, page 8:
// "standardized rendering pixel size" is 0.28 mm.
//
return new WmtsTileMatrix(identifier,
1d / (scaleDenominator * 0.00028),
topLeft, tileWidth, tileHeight, matrixWidth, matrixHeight);
} }
var supportedCrs = tileMatrixSetElement.Element(ows + "SupportedCRS")?.Value;
if (string.IsNullOrEmpty(supportedCrs))
{
throw new ArgumentException($"No SupportedCRS element found in TileMatrixSet \"{identifier}\".");
}
const string urnPrefix = "urn:ogc:def:crs:";
if (supportedCrs.StartsWith(urnPrefix)) // e.g. "urn:ogc:def:crs:EPSG:6.18:3857")
{
var crsComponents = supportedCrs.Substring(urnPrefix.Length).Split(':');
supportedCrs = crsComponents.First() + ":" + crsComponents.Last();
}
var tileMatrixes = new List<WmtsTileMatrix>();
foreach (var tileMatrixElement in tileMatrixSetElement.Elements(wmts + "TileMatrix"))
{
tileMatrixes.Add(ReadTileMatrix(tileMatrixElement, supportedCrs));
}
if (tileMatrixes.Count <= 0)
{
throw new ArgumentException($"No TileMatrix elements found in TileMatrixSet \"{identifier}\".");
}
return new WmtsTileMatrixSet(identifier, supportedCrs, uriTemplate, tileMatrixes);
}
public static WmtsTileMatrix ReadTileMatrix(XElement tileMatrixElement, string supportedCrs)
{
var identifier = tileMatrixElement.Element(ows + "Identifier")?.Value;
if (string.IsNullOrEmpty(identifier))
{
throw new ArgumentException("No Identifier element found in TileMatrix.");
}
var valueString = tileMatrixElement.Element(wmts + "ScaleDenominator")?.Value;
if (string.IsNullOrEmpty(valueString) ||
!double.TryParse(valueString, NumberStyles.Float, CultureInfo.InvariantCulture, out double scaleDenominator))
{
throw new ArgumentException($"No ScaleDenominator element found in TileMatrix \"{identifier}\".");
}
valueString = tileMatrixElement.Element(wmts + "TopLeftCorner")?.Value;
string[] topLeftCornerStrings;
if (string.IsNullOrEmpty(valueString) ||
(topLeftCornerStrings = valueString.Split([' '], StringSplitOptions.RemoveEmptyEntries)).Length < 2 ||
!double.TryParse(topLeftCornerStrings[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double left) ||
!double.TryParse(topLeftCornerStrings[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double top))
{
throw new ArgumentException($"No TopLeftCorner element found in TileMatrix \"{identifier}\".");
}
valueString = tileMatrixElement.Element(wmts + "TileWidth")?.Value;
if (string.IsNullOrEmpty(valueString) || !int.TryParse(valueString, out int tileWidth))
{
throw new ArgumentException($"No TileWidth element found in TileMatrix \"{identifier}\".");
}
valueString = tileMatrixElement.Element(wmts + "TileHeight")?.Value;
if (string.IsNullOrEmpty(valueString) || !int.TryParse(valueString, out int tileHeight))
{
throw new ArgumentException($"No TileHeight element found in TileMatrix \"{identifier}\".");
}
valueString = tileMatrixElement.Element(wmts + "MatrixWidth")?.Value;
if (string.IsNullOrEmpty(valueString) || !int.TryParse(valueString, out int matrixWidth))
{
throw new ArgumentException($"No MatrixWidth element found in TileMatrix \"{identifier}\".");
}
valueString = tileMatrixElement.Element(wmts + "MatrixHeight")?.Value;
if (string.IsNullOrEmpty(valueString) || !int.TryParse(valueString, out int matrixHeight))
{
throw new ArgumentException($"No MatrixHeight element found in TileMatrix \"{identifier}\".");
}
var topLeft = supportedCrs == "EPSG:4326"
? new Point(MapProjection.Wgs84MeterPerDegree * top, MapProjection.Wgs84MeterPerDegree * left)
: new Point(left, top);
// See 07-057r7_Web_Map_Tile_Service_Standard.pdf, section 6.1.a, page 8:
// "standardized rendering pixel size" is 0.28 mm.
//
return new WmtsTileMatrix(identifier,
1d / (scaleDenominator * 0.00028),
topLeft, tileWidth, tileHeight, matrixWidth, matrixHeight);
} }
} }

View file

@ -15,212 +15,211 @@ using Avalonia;
using Avalonia.Interactivity; using Avalonia.Interactivity;
#endif #endif
namespace MapControl namespace MapControl;
{
#if WPF #if WPF
using TileMatrixLayer = DrawingTileMatrixLayer; using TileMatrixLayer = DrawingTileMatrixLayer;
#else #else
using TileMatrixLayer = WmtsTileMatrixLayer; using TileMatrixLayer = WmtsTileMatrixLayer;
#endif #endif
/// <summary> /// <summary>
/// Displays map tiles from a Web Map Tile Service (WMTS). /// Displays map tiles from a Web Map Tile Service (WMTS).
/// </summary> /// </summary>
public partial class WmtsTileLayer : TilePyramidLayer public partial class WmtsTileLayer : TilePyramidLayer
{
private static ILogger Logger => field ??= ImageLoader.LoggerFactory?.CreateLogger(typeof(WmtsTileLayer));
public static readonly DependencyProperty CapabilitiesUriProperty =
DependencyPropertyHelper.Register<WmtsTileLayer, Uri>(nameof(CapabilitiesUri));
public static readonly DependencyProperty LayerProperty =
DependencyPropertyHelper.Register<WmtsTileLayer, string>(nameof(Layer));
public static readonly DependencyProperty PreferredTileMatrixSetsProperty =
DependencyPropertyHelper.Register<WmtsTileLayer, string[]>(nameof(PreferredTileMatrixSets));
public WmtsTileLayer()
{ {
private static ILogger Logger => field ??= ImageLoader.LoggerFactory?.CreateLogger(typeof(WmtsTileLayer)); Loaded += OnLoaded;
}
public static readonly DependencyProperty CapabilitiesUriProperty = /// <summary>
DependencyPropertyHelper.Register<WmtsTileLayer, Uri>(nameof(CapabilitiesUri)); /// The Uri of a XML file or web response that contains the service capabilities.
/// </summary>
public Uri CapabilitiesUri
{
get => (Uri)GetValue(CapabilitiesUriProperty);
set => SetValue(CapabilitiesUriProperty, value);
}
public static readonly DependencyProperty LayerProperty = /// <summary>
DependencyPropertyHelper.Register<WmtsTileLayer, string>(nameof(Layer)); /// The Identifier of the Layer that should be displayed.
/// If not set, the first Layer defined in WMTS Capabilities is displayed.
/// </summary>
public string Layer
{
get => (string)GetValue(LayerProperty);
set => SetValue(LayerProperty, value);
}
public static readonly DependencyProperty PreferredTileMatrixSetsProperty = /// <summary>
DependencyPropertyHelper.Register<WmtsTileLayer, string[]>(nameof(PreferredTileMatrixSets)); /// In case there are TileMatrixSets with identical SupportedCRS values,
/// the ones with Identifiers contained in this collection take precedence.
/// </summary>
public string[] PreferredTileMatrixSets
{
get => (string[])GetValue(PreferredTileMatrixSetsProperty);
set => SetValue(PreferredTileMatrixSetsProperty, value);
}
public WmtsTileLayer() /// <summary>
/// Gets a dictionary of all tile matrix sets supported by a WMTS,
/// with their CRS identifiers as dictionary keys.
/// </summary>
public Dictionary<string, WmtsTileMatrixSet> TileMatrixSets { get; } = [];
protected IEnumerable<TileMatrixLayer> ChildLayers => Children.Cast<TileMatrixLayer>();
protected override Size MeasureOverride(Size availableSize)
{
foreach (var layer in ChildLayers)
{ {
Loaded += OnLoaded; layer.Measure(availableSize);
} }
/// <summary> return new Size();
/// The Uri of a XML file or web response that contains the service capabilities. }
/// </summary>
public Uri CapabilitiesUri protected override Size ArrangeOverride(Size finalSize)
{
foreach (var layer in ChildLayers)
{ {
get => (Uri)GetValue(CapabilitiesUriProperty); layer.Arrange(new Rect(0d, 0d, finalSize.Width, finalSize.Height));
set => SetValue(CapabilitiesUriProperty, value);
} }
/// <summary> return finalSize;
/// The Identifier of the Layer that should be displayed. }
/// If not set, the first Layer defined in WMTS Capabilities is displayed.
/// </summary> protected override void UpdateRenderTransform()
public string Layer {
foreach (var layer in ChildLayers)
{ {
get => (string)GetValue(LayerProperty); layer.UpdateRenderTransform(ParentMap.ViewTransform);
set => SetValue(LayerProperty, value);
} }
}
/// <summary> protected override void UpdateTileCollection()
/// In case there are TileMatrixSets with identical SupportedCRS values, {
/// the ones with Identifiers contained in this collection take precedence. if (ParentMap == null ||
/// </summary> !TileMatrixSets.TryGetValue(ParentMap.MapProjection.CrsId, out WmtsTileMatrixSet tileMatrixSet))
public string[] PreferredTileMatrixSets
{ {
get => (string[])GetValue(PreferredTileMatrixSetsProperty);
set => SetValue(PreferredTileMatrixSetsProperty, value);
}
/// <summary>
/// Gets a dictionary of all tile matrix sets supported by a WMTS,
/// with their CRS identifiers as dictionary keys.
/// </summary>
public Dictionary<string, WmtsTileMatrixSet> TileMatrixSets { get; } = [];
protected IEnumerable<TileMatrixLayer> ChildLayers => Children.Cast<TileMatrixLayer>();
protected override Size MeasureOverride(Size availableSize)
{
foreach (var layer in ChildLayers)
{
layer.Measure(availableSize);
}
return new Size();
}
protected override Size ArrangeOverride(Size finalSize)
{
foreach (var layer in ChildLayers)
{
layer.Arrange(new Rect(0d, 0d, finalSize.Width, finalSize.Height));
}
return finalSize;
}
protected override void UpdateRenderTransform()
{
foreach (var layer in ChildLayers)
{
layer.UpdateRenderTransform(ParentMap.ViewTransform);
}
}
protected override void UpdateTileCollection()
{
if (ParentMap == null ||
!TileMatrixSets.TryGetValue(ParentMap.MapProjection.CrsId, out WmtsTileMatrixSet tileMatrixSet))
{
Children.Clear();
CancelLoadTiles();
}
else if (UpdateChildLayers(tileMatrixSet.TileMatrixes))
{
var tileSource = new WmtsTileSource(tileMatrixSet);
var cacheName = SourceName;
if (!string.IsNullOrEmpty(cacheName))
{
if (!string.IsNullOrEmpty(Layer))
{
cacheName += "/" + Layer.Replace(':', '_');
}
if (!string.IsNullOrEmpty(tileMatrixSet.Identifier))
{
cacheName += "/" + tileMatrixSet.Identifier.Replace(':', '_');
}
}
BeginLoadTiles(ChildLayers.SelectMany(layer => layer.Tiles), tileSource, cacheName);
}
}
private bool UpdateChildLayers(IList<WmtsTileMatrix> tileMatrixSet)
{
// Multiply scale by 1.001 to avoid floating point precision issues
// and get all WmtsTileMatrixes with Scale <= maxScale.
//
var maxScale = 1.001 * ParentMap.ViewTransform.Scale;
var tileMatrixes = tileMatrixSet.Where(matrix => matrix.Scale <= maxScale).ToList();
if (tileMatrixes.Count == 0)
{
Children.Clear();
return false;
}
var maxLayers = Math.Max(MaxBackgroundLevels, 0) + 1;
if (!IsBaseMapLayer)
{
// Show only the last layer.
//
tileMatrixes = tileMatrixes.GetRange(tileMatrixes.Count - 1, 1);
}
else if (tileMatrixes.Count > maxLayers)
{
// Show not more than MaxBackgroundLevels + 1 layers.
//
tileMatrixes = tileMatrixes.GetRange(tileMatrixes.Count - maxLayers, maxLayers);
}
// Get reusable layers.
//
var layers = ChildLayers.Where(layer => tileMatrixes.Contains(layer.WmtsTileMatrix)).ToList();
var tilesChanged = false;
Children.Clear(); Children.Clear();
CancelLoadTiles();
}
else if (UpdateChildLayers(tileMatrixSet.TileMatrixes))
{
var tileSource = new WmtsTileSource(tileMatrixSet);
var cacheName = SourceName;
foreach (var tileMatrix in tileMatrixes) if (!string.IsNullOrEmpty(cacheName))
{ {
// Pass index of tileMatrix in tileMatrixSet as zoom level to TileMatrixLayer ctor. if (!string.IsNullOrEmpty(Layer))
//
var layer = layers.FirstOrDefault(layer => layer.WmtsTileMatrix == tileMatrix) ??
new TileMatrixLayer(tileMatrix, tileMatrixSet.IndexOf(tileMatrix));
if (layer.UpdateTiles(ParentMap.ViewTransform, ParentMap.ActualWidth, ParentMap.ActualHeight))
{ {
tilesChanged = true; cacheName += "/" + Layer.Replace(':', '_');
} }
layer.UpdateRenderTransform(ParentMap.ViewTransform); if (!string.IsNullOrEmpty(tileMatrixSet.Identifier))
{
Children.Add(layer); cacheName += "/" + tileMatrixSet.Identifier.Replace(':', '_');
}
} }
return tilesChanged; BeginLoadTiles(ChildLayers.SelectMany(layer => layer.Tiles), tileSource, cacheName);
}
}
private bool UpdateChildLayers(IList<WmtsTileMatrix> tileMatrixSet)
{
// Multiply scale by 1.001 to avoid floating point precision issues
// and get all WmtsTileMatrixes with Scale <= maxScale.
//
var maxScale = 1.001 * ParentMap.ViewTransform.Scale;
var tileMatrixes = tileMatrixSet.Where(matrix => matrix.Scale <= maxScale).ToList();
if (tileMatrixes.Count == 0)
{
Children.Clear();
return false;
} }
private async void OnLoaded(object sender, RoutedEventArgs e) var maxLayers = Math.Max(MaxBackgroundLevels, 0) + 1;
if (!IsBaseMapLayer)
{ {
Loaded -= OnLoaded; // Show only the last layer.
//
tileMatrixes = tileMatrixes.GetRange(tileMatrixes.Count - 1, 1);
}
else if (tileMatrixes.Count > maxLayers)
{
// Show not more than MaxBackgroundLevels + 1 layers.
//
tileMatrixes = tileMatrixes.GetRange(tileMatrixes.Count - maxLayers, maxLayers);
}
if (TileMatrixSets.Count == 0 && CapabilitiesUri != null) // Get reusable layers.
//
var layers = ChildLayers.Where(layer => tileMatrixes.Contains(layer.WmtsTileMatrix)).ToList();
var tilesChanged = false;
Children.Clear();
foreach (var tileMatrix in tileMatrixes)
{
// Pass index of tileMatrix in tileMatrixSet as zoom level to TileMatrixLayer ctor.
//
var layer = layers.FirstOrDefault(layer => layer.WmtsTileMatrix == tileMatrix) ??
new TileMatrixLayer(tileMatrix, tileMatrixSet.IndexOf(tileMatrix));
if (layer.UpdateTiles(ParentMap.ViewTransform, ParentMap.ActualWidth, ParentMap.ActualHeight))
{ {
try tilesChanged = true;
}
layer.UpdateRenderTransform(ParentMap.ViewTransform);
Children.Add(layer);
}
return tilesChanged;
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
Loaded -= OnLoaded;
if (TileMatrixSets.Count == 0 && CapabilitiesUri != null)
{
try
{
var capabilities = await WmtsCapabilities.ReadCapabilitiesAsync(CapabilitiesUri, Layer);
Layer = capabilities.Layer;
foreach (var tms in capabilities.TileMatrixSets
.Where(tms => !TileMatrixSets.ContainsKey(tms.SupportedCrsId) ||
PreferredTileMatrixSets != null &&
PreferredTileMatrixSets.Contains(tms.Identifier)))
{ {
var capabilities = await WmtsCapabilities.ReadCapabilitiesAsync(CapabilitiesUri, Layer); TileMatrixSets[tms.SupportedCrsId] = tms;
Layer = capabilities.Layer;
foreach (var tms in capabilities.TileMatrixSets
.Where(tms => !TileMatrixSets.ContainsKey(tms.SupportedCrsId) ||
PreferredTileMatrixSets != null &&
PreferredTileMatrixSets.Contains(tms.Identifier)))
{
TileMatrixSets[tms.SupportedCrsId] = tms;
}
UpdateTileCollection();
}
catch (Exception ex)
{
Logger?.LogError(ex, "Failed reading capabilities from {uri}", CapabilitiesUri);
} }
UpdateTileCollection();
}
catch (Exception ex)
{
Logger?.LogError(ex, "Failed reading capabilities from {uri}", CapabilitiesUri);
} }
} }
} }

View file

@ -5,22 +5,21 @@ using System.Windows;
using Avalonia; using Avalonia;
#endif #endif
namespace MapControl namespace MapControl;
{
public class WmtsTileMatrix(
string identifier, double scale, Point topLeft, int tileWidth, int tileHeight, int matrixWidth, int matrixHeight)
{
public string Identifier => identifier;
public double Scale => scale;
public Point TopLeft => topLeft;
public int TileWidth => tileWidth;
public int TileHeight => tileHeight;
public int MatrixWidth => matrixWidth;
public int MatrixHeight => matrixHeight;
// Indicates if the total width in meters matches the earth circumference. public class WmtsTileMatrix(
// string identifier, double scale, Point topLeft, int tileWidth, int tileHeight, int matrixWidth, int matrixHeight)
public bool HasFullHorizontalCoverage { get; } = {
Math.Abs(matrixWidth * tileWidth / scale - 360d * MapProjection.Wgs84MeterPerDegree) < 1e-3; public string Identifier => identifier;
} public double Scale => scale;
public Point TopLeft => topLeft;
public int TileWidth => tileWidth;
public int TileHeight => tileHeight;
public int MatrixWidth => matrixWidth;
public int MatrixHeight => matrixHeight;
// Indicates if the total width in meters matches the earth circumference.
//
public bool HasFullHorizontalCoverage { get; } =
Math.Abs(matrixWidth * tileWidth / scale - 360d * MapProjection.Wgs84MeterPerDegree) < 1e-3;
} }

View file

@ -18,107 +18,106 @@ using Avalonia.Controls;
using Avalonia.Media; using Avalonia.Media;
#endif #endif
namespace MapControl namespace MapControl;
public partial class WmtsTileMatrixLayer : Panel
{ {
public partial class WmtsTileMatrixLayer : Panel public WmtsTileMatrixLayer(WmtsTileMatrix wmtsTileMatrix, int zoomLevel)
{ {
public WmtsTileMatrixLayer(WmtsTileMatrix wmtsTileMatrix, int zoomLevel) this.SetRenderTransform(new MatrixTransform());
WmtsTileMatrix = wmtsTileMatrix;
TileMatrix = new TileMatrix(zoomLevel, 1, 1, 0, 0);
}
public WmtsTileMatrix WmtsTileMatrix { get; }
public TileMatrix TileMatrix { get; private set; }
public IEnumerable<ImageTile> Tiles { get; private set; } = [];
public void UpdateRenderTransform(ViewTransform viewTransform)
{
// Tile matrix origin in pixels.
//
var tileMatrixOrigin = new Point(WmtsTileMatrix.TileWidth * TileMatrix.XMin, WmtsTileMatrix.TileHeight * TileMatrix.YMin);
((MatrixTransform)RenderTransform).Matrix =
viewTransform.GetTileLayerTransform(WmtsTileMatrix.Scale, WmtsTileMatrix.TopLeft, tileMatrixOrigin);
}
public bool UpdateTiles(ViewTransform viewTransform, double viewWidth, double viewHeight)
{
// Tile matrix bounds in pixels.
//
var bounds = viewTransform.GetTileMatrixBounds(WmtsTileMatrix.Scale, WmtsTileMatrix.TopLeft, viewWidth, viewHeight);
// Tile X and Y bounds.
//
var xMin = (int)Math.Floor(bounds.X / WmtsTileMatrix.TileWidth);
var yMin = (int)Math.Floor(bounds.Y / WmtsTileMatrix.TileHeight);
var xMax = (int)Math.Floor((bounds.X + bounds.Width) / WmtsTileMatrix.TileWidth);
var yMax = (int)Math.Floor((bounds.Y + bounds.Height) / WmtsTileMatrix.TileHeight);
if (!WmtsTileMatrix.HasFullHorizontalCoverage)
{ {
this.SetRenderTransform(new MatrixTransform()); // Set X range limits.
WmtsTileMatrix = wmtsTileMatrix;
TileMatrix = new TileMatrix(zoomLevel, 1, 1, 0, 0);
}
public WmtsTileMatrix WmtsTileMatrix { get; }
public TileMatrix TileMatrix { get; private set; }
public IEnumerable<ImageTile> Tiles { get; private set; } = [];
public void UpdateRenderTransform(ViewTransform viewTransform)
{
// Tile matrix origin in pixels.
// //
var tileMatrixOrigin = new Point(WmtsTileMatrix.TileWidth * TileMatrix.XMin, WmtsTileMatrix.TileHeight * TileMatrix.YMin); xMin = Math.Max(xMin, 0);
xMax = Math.Min(Math.Max(xMax, 0), WmtsTileMatrix.MatrixWidth - 1);
((MatrixTransform)RenderTransform).Matrix =
viewTransform.GetTileLayerTransform(WmtsTileMatrix.Scale, WmtsTileMatrix.TopLeft, tileMatrixOrigin);
} }
public bool UpdateTiles(ViewTransform viewTransform, double viewWidth, double viewHeight) // Set Y range limits.
//
yMin = Math.Max(yMin, 0);
yMax = Math.Min(Math.Max(yMax, 0), WmtsTileMatrix.MatrixHeight - 1);
if (TileMatrix.XMin == xMin && TileMatrix.YMin == yMin &&
TileMatrix.XMax == xMax && TileMatrix.YMax == yMax)
{ {
// Tile matrix bounds in pixels. // No change of the TileMatrix and the Tiles collection.
// //
var bounds = viewTransform.GetTileMatrixBounds(WmtsTileMatrix.Scale, WmtsTileMatrix.TopLeft, viewWidth, viewHeight); return false;
// Tile X and Y bounds.
//
var xMin = (int)Math.Floor(bounds.X / WmtsTileMatrix.TileWidth);
var yMin = (int)Math.Floor(bounds.Y / WmtsTileMatrix.TileHeight);
var xMax = (int)Math.Floor((bounds.X + bounds.Width) / WmtsTileMatrix.TileWidth);
var yMax = (int)Math.Floor((bounds.Y + bounds.Height) / WmtsTileMatrix.TileHeight);
if (!WmtsTileMatrix.HasFullHorizontalCoverage)
{
// Set X range limits.
//
xMin = Math.Max(xMin, 0);
xMax = Math.Min(Math.Max(xMax, 0), WmtsTileMatrix.MatrixWidth - 1);
}
// Set Y range limits.
//
yMin = Math.Max(yMin, 0);
yMax = Math.Min(Math.Max(yMax, 0), WmtsTileMatrix.MatrixHeight - 1);
if (TileMatrix.XMin == xMin && TileMatrix.YMin == yMin &&
TileMatrix.XMax == xMax && TileMatrix.YMax == yMax)
{
// No change of the TileMatrix and the Tiles collection.
//
return false;
}
TileMatrix = new TileMatrix(TileMatrix.ZoomLevel, xMin, yMin, xMax, yMax);
Tiles = new ImageTileList(Tiles, TileMatrix, WmtsTileMatrix.MatrixWidth);
Children.Clear();
foreach (var tile in Tiles)
{
Children.Add(tile.Image);
}
return true;
} }
protected override Size MeasureOverride(Size availableSize) TileMatrix = new TileMatrix(TileMatrix.ZoomLevel, xMin, yMin, xMax, yMax);
Tiles = new ImageTileList(Tiles, TileMatrix, WmtsTileMatrix.MatrixWidth);
Children.Clear();
foreach (var tile in Tiles)
{ {
foreach (var tile in Tiles) Children.Add(tile.Image);
{
tile.Image.Measure(availableSize);
}
return new Size();
} }
protected override Size ArrangeOverride(Size finalSize) return true;
}
protected override Size MeasureOverride(Size availableSize)
{
foreach (var tile in Tiles)
{ {
foreach (var tile in Tiles) tile.Image.Measure(availableSize);
{
// Arrange tiles relative to TileMatrix.XMin/YMin.
//
var width = WmtsTileMatrix.TileWidth;
var height = WmtsTileMatrix.TileHeight;
var x = width * (tile.X - TileMatrix.XMin);
var y = height * (tile.Y - TileMatrix.YMin);
tile.Image.Width = width;
tile.Image.Height = height;
tile.Image.Arrange(new Rect(x, y, width, height));
}
return finalSize;
} }
return new Size();
}
protected override Size ArrangeOverride(Size finalSize)
{
foreach (var tile in Tiles)
{
// Arrange tiles relative to TileMatrix.XMin/YMin.
//
var width = WmtsTileMatrix.TileWidth;
var height = WmtsTileMatrix.TileHeight;
var x = width * (tile.X - TileMatrix.XMin);
var y = height * (tile.Y - TileMatrix.YMin);
tile.Image.Width = width;
tile.Image.Height = height;
tile.Image.Arrange(new Rect(x, y, width, height));
}
return finalSize;
} }
} }

View file

@ -6,42 +6,41 @@ using System.Windows;
using Avalonia; using Avalonia;
#endif #endif
namespace MapControl namespace MapControl;
public class WmtsTileMatrixSet(
string identifier, string supportedCrsId, string uriTemplate, IEnumerable<WmtsTileMatrix> tileMatrixes)
{ {
public class WmtsTileMatrixSet( public string Identifier => identifier;
string identifier, string supportedCrsId, string uriTemplate, IEnumerable<WmtsTileMatrix> tileMatrixes) public string SupportedCrsId => supportedCrsId;
public string UriTemplate { get; } = uriTemplate.Replace("{TileMatrixSet}", identifier);
public List<WmtsTileMatrix> TileMatrixes { get; } = tileMatrixes.OrderBy(m => m.Scale).ToList();
public static WmtsTileMatrixSet CreateOpenStreetMapTileMatrixSet(
string uriTemplate, int minZoomLevel = 0, int maxZoomLevel = 19)
{ {
public string Identifier => identifier; static WmtsTileMatrix CreateWmtsTileMatrix(int zoomLevel)
public string SupportedCrsId => supportedCrsId;
public string UriTemplate { get; } = uriTemplate.Replace("{TileMatrixSet}", identifier);
public List<WmtsTileMatrix> TileMatrixes { get; } = tileMatrixes.OrderBy(m => m.Scale).ToList();
public static WmtsTileMatrixSet CreateOpenStreetMapTileMatrixSet(
string uriTemplate, int minZoomLevel = 0, int maxZoomLevel = 19)
{ {
static WmtsTileMatrix CreateWmtsTileMatrix(int zoomLevel) const int tileSize = 256;
{ const double origin = 180d * MapProjection.Wgs84MeterPerDegree;
const int tileSize = 256;
const double origin = 180d * MapProjection.Wgs84MeterPerDegree;
var matrixSize = 1 << zoomLevel; var matrixSize = 1 << zoomLevel;
var scale = matrixSize * tileSize / (2d * origin); var scale = matrixSize * tileSize / (2d * origin);
return new WmtsTileMatrix( return new WmtsTileMatrix(
zoomLevel.ToString(), scale, new Point(-origin, origin), zoomLevel.ToString(), scale, new Point(-origin, origin),
tileSize, tileSize, matrixSize, matrixSize); tileSize, tileSize, matrixSize, matrixSize);
}
return new WmtsTileMatrixSet(
null,
WebMercatorProjection.DefaultCrsId,
uriTemplate
.Replace("{z}", "{0}")
.Replace("{x}", "{1}")
.Replace("{y}", "{2}"),
Enumerable
.Range(minZoomLevel, maxZoomLevel - minZoomLevel + 1)
.Select(CreateWmtsTileMatrix));
} }
return new WmtsTileMatrixSet(
null,
WebMercatorProjection.DefaultCrsId,
uriTemplate
.Replace("{z}", "{0}")
.Replace("{x}", "{1}")
.Replace("{y}", "{2}"),
Enumerable
.Range(minZoomLevel, maxZoomLevel - minZoomLevel + 1)
.Select(CreateWmtsTileMatrix));
} }
} }

View file

@ -1,22 +1,21 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace MapControl namespace MapControl;
public class WmtsTileSource(WmtsTileMatrixSet tileMatrixSet) : TileSource
{ {
public class WmtsTileSource(WmtsTileMatrixSet tileMatrixSet) : TileSource private readonly string uriFormat = tileMatrixSet.UriTemplate
.Replace("{TileMatrix}", "{0}")
.Replace("{TileCol}", "{1}")
.Replace("{TileRow}", "{2}");
private readonly List<WmtsTileMatrix> tileMatrixes = tileMatrixSet.TileMatrixes;
public override Uri GetUri(int zoomLevel, int column, int row)
{ {
private readonly string uriFormat = tileMatrixSet.UriTemplate return zoomLevel < tileMatrixes.Count
.Replace("{TileMatrix}", "{0}") ? new Uri(string.Format(uriFormat, tileMatrixes[zoomLevel].Identifier, column, row))
.Replace("{TileCol}", "{1}") : null;
.Replace("{TileRow}", "{2}");
private readonly List<WmtsTileMatrix> tileMatrixes = tileMatrixSet.TileMatrixes;
public override Uri GetUri(int zoomLevel, int column, int row)
{
return zoomLevel < tileMatrixes.Count
? new Uri(string.Format(uriFormat, tileMatrixes[zoomLevel].Identifier, column, row))
: null;
}
} }
} }

View file

@ -6,84 +6,83 @@ using System.Windows.Media;
using Avalonia; using Avalonia;
#endif #endif
namespace MapControl namespace MapControl;
/// <summary>
/// Elliptical Mercator Projection - EPSG:3395.
/// See "Map Projections - A Working Manual" (https://pubs.usgs.gov/publication/pp1395), p.44-45.
/// </summary>
public class WorldMercatorProjection : MapProjection
{ {
/// <summary> public const string DefaultCrsId = "EPSG:3395";
/// Elliptical Mercator Projection - EPSG:3395.
/// See "Map Projections - A Working Manual" (https://pubs.usgs.gov/publication/pp1395), p.44-45. public WorldMercatorProjection() // parameterless constructor for XAML
/// </summary> : this(DefaultCrsId)
public class WorldMercatorProjection : MapProjection
{ {
public const string DefaultCrsId = "EPSG:3395"; }
public WorldMercatorProjection() // parameterless constructor for XAML public WorldMercatorProjection(string crsId)
: this(DefaultCrsId) {
IsNormalCylindrical = true;
CrsId = crsId;
}
public override Matrix RelativeTransform(double latitude, double longitude)
{
var e2 = (2d - Flattening) * Flattening;
var phi = latitude * Math.PI / 180d;
var sinPhi = Math.Sin(phi);
var k = Math.Sqrt(1d - e2 * sinPhi * sinPhi) / Math.Cos(phi); // p.44 (7-8)
return new Matrix(k, 0d, 0d, k, 0d, 0d);
}
public override Point LocationToMap(double latitude, double longitude)
{
var x = EquatorialRadius * longitude * Math.PI / 180d;
double y;
if (latitude <= -90d)
{ {
y = double.NegativeInfinity;
} }
else if (latitude >= 90d)
public WorldMercatorProjection(string crsId)
{ {
IsNormalCylindrical = true; y = double.PositiveInfinity;
CrsId = crsId;
} }
else
public override Matrix RelativeTransform(double latitude, double longitude)
{ {
var e2 = (2d - Flattening) * Flattening;
var phi = latitude * Math.PI / 180d; var phi = latitude * Math.PI / 180d;
var sinPhi = Math.Sin(phi); var e = Math.Sqrt((2d - Flattening) * Flattening);
var k = Math.Sqrt(1d - e2 * sinPhi * sinPhi) / Math.Cos(phi); // p.44 (7-8) var eSinPhi = e * Math.Sin(phi);
var p = Math.Pow((1d - eSinPhi) / (1d + eSinPhi), e / 2d);
return new Matrix(k, 0d, 0d, k, 0d, 0d); y = EquatorialRadius * Math.Log(Math.Tan(phi / 2d + Math.PI / 4d) * p); // p.44 (7-7)
} }
public override Point LocationToMap(double latitude, double longitude) return new Point(x, y);
{ }
var x = EquatorialRadius * longitude * Math.PI / 180d;
double y;
if (latitude <= -90d) public override Location MapToLocation(double x, double y)
{ {
y = double.NegativeInfinity; var t = Math.Exp(-y / EquatorialRadius); // p.44 (7-10)
} var phi = ApproximateLatitude((2d - Flattening) * Flattening, t); // p.45 (3-5)
else if (latitude >= 90d) var lambda = x / EquatorialRadius;
{
y = double.PositiveInfinity;
}
else
{
var phi = latitude * Math.PI / 180d;
var e = Math.Sqrt((2d - Flattening) * Flattening);
var eSinPhi = e * Math.Sin(phi);
var p = Math.Pow((1d - eSinPhi) / (1d + eSinPhi), e / 2d);
y = EquatorialRadius * Math.Log(Math.Tan(phi / 2d + Math.PI / 4d) * p); // p.44 (7-7) return new Location(phi * 180d / Math.PI, lambda * 180d / Math.PI);
} }
return new Point(x, y); internal static double ApproximateLatitude(double e2, double t)
} {
var e4 = e2 * e2;
var e6 = e2 * e4;
var e8 = e2 * e6;
var chi = Math.PI / 2d - 2d * Math.Atan(t); // p.45 (7-13)
public override Location MapToLocation(double x, double y) return chi +
{ (e2 / 2d + e4 * 5d / 24d + e6 / 12d + e8 * 13d / 360d) * Math.Sin(2d * chi) +
var t = Math.Exp(-y / EquatorialRadius); // p.44 (7-10) (e4 * 7d / 48d + e6 * 29d / 240d + e8 * 811d / 11520d) * Math.Sin(4d * chi) +
var phi = ApproximateLatitude((2d - Flattening) * Flattening, t); // p.45 (3-5) (e6 * 7d / 120d + e8 * 81d / 1120d) * Math.Sin(6d * chi) +
var lambda = x / EquatorialRadius; e8 * 4279d / 161280d * Math.Sin(8d * chi); // p.45 (3-5)
return new Location(phi * 180d / Math.PI, lambda * 180d / Math.PI);
}
internal static double ApproximateLatitude(double e2, double t)
{
var e4 = e2 * e2;
var e6 = e2 * e4;
var e8 = e2 * e6;
var chi = Math.PI / 2d - 2d * Math.Atan(t); // p.45 (7-13)
return chi +
(e2 / 2d + e4 * 5d / 24d + e6 / 12d + e8 * 13d / 360d) * Math.Sin(2d * chi) +
(e4 * 7d / 48d + e6 * 29d / 240d + e8 * 811d / 11520d) * Math.Sin(4d * chi) +
(e6 * 7d / 120d + e8 * 81d / 1120d) * Math.Sin(6d * chi) +
e8 * 4279d / 161280d * Math.Sin(8d * chi); // p.45 (3-5)
}
} }
} }

View file

@ -2,18 +2,17 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Xml.Linq; using System.Xml.Linq;
namespace MapControl namespace MapControl;
internal static class XDocument
{ {
internal static class XDocument public static async Task<XElement> LoadRootElementAsync(Stream stream)
{ {
public static async Task<XElement> LoadRootElementAsync(Stream stream)
{
#if NETFRAMEWORK #if NETFRAMEWORK
var document = await Task.Run(() => System.Xml.Linq.XDocument.Load(stream, LoadOptions.None)); var document = await Task.Run(() => System.Xml.Linq.XDocument.Load(stream, LoadOptions.None));
#else #else
var document = await System.Xml.Linq.XDocument.LoadAsync(stream, LoadOptions.None, System.Threading.CancellationToken.None); var document = await System.Xml.Linq.XDocument.LoadAsync(stream, LoadOptions.None, System.Threading.CancellationToken.None);
#endif #endif
return document.Root; return document.Root;
}
} }
} }

View file

@ -6,169 +6,168 @@ using System.Windows;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
namespace MapControl namespace MapControl;
public class BitmapTile(int zoomLevel, int x, int y, int columnCount, int width, int height)
: Tile(zoomLevel, x, y, columnCount)
{ {
public class BitmapTile(int zoomLevel, int x, int y, int columnCount, int width, int height) public event EventHandler Completed;
: Tile(zoomLevel, x, y, columnCount)
public byte[] PixelBuffer { get; set; }
public override async Task LoadImageAsync(Func<Task<ImageSource>> loadImageFunc)
{ {
public event EventHandler Completed; var image = await loadImageFunc().ConfigureAwait(false);
public byte[] PixelBuffer { get; set; } if (image is BitmapSource bitmap)
public override async Task LoadImageAsync(Func<Task<ImageSource>> loadImageFunc)
{ {
var image = await loadImageFunc().ConfigureAwait(false); if (bitmap.Format != PixelFormats.Pbgra32)
if (image is BitmapSource bitmap)
{ {
if (bitmap.Format != PixelFormats.Pbgra32) bitmap = new FormatConvertedBitmap(bitmap, PixelFormats.Pbgra32, null, 0d);
{
bitmap = new FormatConvertedBitmap(bitmap, PixelFormats.Pbgra32, null, 0d);
}
PixelBuffer = new byte[4 * width * height];
bitmap.CopyPixels(PixelBuffer, 4 * width, 0);
} }
Completed?.Invoke(this, EventArgs.Empty); PixelBuffer = new byte[4 * width * height];
bitmap.CopyPixels(PixelBuffer, 4 * width, 0);
} }
Completed?.Invoke(this, EventArgs.Empty);
}
}
public class BitmapTileMatrixLayer(WmtsTileMatrix wmtsTileMatrix, int zoomLevel) : UIElement
{
private readonly MatrixTransform transform = new MatrixTransform();
private WriteableBitmap bitmap;
public WmtsTileMatrix WmtsTileMatrix => wmtsTileMatrix;
public TileMatrix TileMatrix { get; private set; } = new TileMatrix(zoomLevel, 1, 1, 0, 0);
public IEnumerable<BitmapTile> Tiles { get; private set; } = [];
protected override void OnRender(DrawingContext drawingContext)
{
drawingContext.PushTransform(transform);
drawingContext.DrawImage(bitmap, new Rect(0d, 0d, bitmap.PixelWidth, bitmap.PixelHeight));
} }
public class BitmapTileMatrixLayer(WmtsTileMatrix wmtsTileMatrix, int zoomLevel) : UIElement public void UpdateRenderTransform(ViewTransform viewTransform)
{ {
private readonly MatrixTransform transform = new MatrixTransform(); // Tile matrix origin in pixels.
//
var tileMatrixOrigin = new Point(WmtsTileMatrix.TileWidth * TileMatrix.XMin, WmtsTileMatrix.TileHeight * TileMatrix.YMin);
private WriteableBitmap bitmap; transform.Matrix = viewTransform.GetTileLayerTransform(WmtsTileMatrix.Scale, WmtsTileMatrix.TopLeft, tileMatrixOrigin);
}
public WmtsTileMatrix WmtsTileMatrix => wmtsTileMatrix; public bool UpdateTiles(ViewTransform viewTransform, double viewWidth, double viewHeight)
{
// Tile matrix bounds in pixels.
//
var bounds = viewTransform.GetTileMatrixBounds(WmtsTileMatrix.Scale, WmtsTileMatrix.TopLeft, viewWidth, viewHeight);
public TileMatrix TileMatrix { get; private set; } = new TileMatrix(zoomLevel, 1, 1, 0, 0); // Tile X and Y bounds.
//
var xMin = (int)Math.Floor(bounds.X / WmtsTileMatrix.TileWidth);
var yMin = (int)Math.Floor(bounds.Y / WmtsTileMatrix.TileHeight);
var xMax = (int)Math.Floor((bounds.X + bounds.Width) / WmtsTileMatrix.TileWidth);
var yMax = (int)Math.Floor((bounds.Y + bounds.Height) / WmtsTileMatrix.TileHeight);
public IEnumerable<BitmapTile> Tiles { get; private set; } = []; if (!WmtsTileMatrix.HasFullHorizontalCoverage)
protected override void OnRender(DrawingContext drawingContext)
{ {
drawingContext.PushTransform(transform); // Set X range limits.
drawingContext.DrawImage(bitmap, new Rect(0d, 0d, bitmap.PixelWidth, bitmap.PixelHeight)); //
xMin = Math.Max(xMin, 0);
xMax = Math.Min(Math.Max(xMax, 0), WmtsTileMatrix.MatrixWidth - 1);
} }
public void UpdateRenderTransform(ViewTransform viewTransform) // Set Y range limits.
{ //
// Tile matrix origin in pixels. yMin = Math.Max(yMin, 0);
// yMax = Math.Min(Math.Max(yMax, 0), WmtsTileMatrix.MatrixHeight - 1);
var tileMatrixOrigin = new Point(WmtsTileMatrix.TileWidth * TileMatrix.XMin, WmtsTileMatrix.TileHeight * TileMatrix.YMin);
transform.Matrix = viewTransform.GetTileLayerTransform(WmtsTileMatrix.Scale, WmtsTileMatrix.TopLeft, tileMatrixOrigin); if (TileMatrix.XMin == xMin && TileMatrix.YMin == yMin &&
TileMatrix.XMax == xMax && TileMatrix.YMax == yMax)
{
// No change of the TileMatrix and the Tiles collection.
//
return false;
} }
public bool UpdateTiles(ViewTransform viewTransform, double viewWidth, double viewHeight) TileMatrix = new TileMatrix(TileMatrix.ZoomLevel, xMin, yMin, xMax, yMax);
bitmap = new WriteableBitmap(
WmtsTileMatrix.TileWidth * TileMatrix.Width,
WmtsTileMatrix.TileHeight * TileMatrix.Height,
96, 96, PixelFormats.Pbgra32, null);
CreateTiles();
InvalidateVisual();
return true;
}
private void CreateTiles()
{
var tiles = new List<BitmapTile>(TileMatrix.Width * TileMatrix.Height);
for (var y = TileMatrix.YMin; y <= TileMatrix.YMax; y++)
{ {
// Tile matrix bounds in pixels. for (var x = TileMatrix.XMin; x <= TileMatrix.XMax; x++)
//
var bounds = viewTransform.GetTileMatrixBounds(WmtsTileMatrix.Scale, WmtsTileMatrix.TopLeft, viewWidth, viewHeight);
// Tile X and Y bounds.
//
var xMin = (int)Math.Floor(bounds.X / WmtsTileMatrix.TileWidth);
var yMin = (int)Math.Floor(bounds.Y / WmtsTileMatrix.TileHeight);
var xMax = (int)Math.Floor((bounds.X + bounds.Width) / WmtsTileMatrix.TileWidth);
var yMax = (int)Math.Floor((bounds.Y + bounds.Height) / WmtsTileMatrix.TileHeight);
if (!WmtsTileMatrix.HasFullHorizontalCoverage)
{ {
// Set X range limits. var tile = Tiles.FirstOrDefault(t => t.X == x && t.Y == y);
//
xMin = Math.Max(xMin, 0);
xMax = Math.Min(Math.Max(xMax, 0), WmtsTileMatrix.MatrixWidth - 1);
}
// Set Y range limits. if (tile == null)
//
yMin = Math.Max(yMin, 0);
yMax = Math.Min(Math.Max(yMax, 0), WmtsTileMatrix.MatrixHeight - 1);
if (TileMatrix.XMin == xMin && TileMatrix.YMin == yMin &&
TileMatrix.XMax == xMax && TileMatrix.YMax == yMax)
{
// No change of the TileMatrix and the Tiles collection.
//
return false;
}
TileMatrix = new TileMatrix(TileMatrix.ZoomLevel, xMin, yMin, xMax, yMax);
bitmap = new WriteableBitmap(
WmtsTileMatrix.TileWidth * TileMatrix.Width,
WmtsTileMatrix.TileHeight * TileMatrix.Height,
96, 96, PixelFormats.Pbgra32, null);
CreateTiles();
InvalidateVisual();
return true;
}
private void CreateTiles()
{
var tiles = new List<BitmapTile>(TileMatrix.Width * TileMatrix.Height);
for (var y = TileMatrix.YMin; y <= TileMatrix.YMax; y++)
{
for (var x = TileMatrix.XMin; x <= TileMatrix.XMax; x++)
{ {
var tile = Tiles.FirstOrDefault(t => t.X == x && t.Y == y); tile = new BitmapTile(TileMatrix.ZoomLevel, x, y, WmtsTileMatrix.MatrixWidth, WmtsTileMatrix.TileWidth, WmtsTileMatrix.TileHeight);
if (tile == null) var equivalentTile = Tiles.FirstOrDefault(t => t.PixelBuffer != null && t.Column == tile.Column && t.Row == tile.Row);
if (equivalentTile != null)
{ {
tile = new BitmapTile(TileMatrix.ZoomLevel, x, y, WmtsTileMatrix.MatrixWidth, WmtsTileMatrix.TileWidth, WmtsTileMatrix.TileHeight); tile.IsPending = false;
tile.PixelBuffer = equivalentTile.PixelBuffer;
var equivalentTile = Tiles.FirstOrDefault(t => t.PixelBuffer != null && t.Column == tile.Column && t.Row == tile.Row);
if (equivalentTile != null)
{
tile.IsPending = false;
tile.PixelBuffer = equivalentTile.PixelBuffer;
}
else
{
tile.Completed += OnTileCompleted;
}
} }
else
if (tile.PixelBuffer != null)
{ {
CopyTile(tile); tile.Completed += OnTileCompleted;
} }
tiles.Add(tile);
} }
}
Tiles = tiles; if (tile.PixelBuffer != null)
{
CopyTile(tile);
}
tiles.Add(tile);
}
} }
private void CopyTile(BitmapTile tile) Tiles = tiles;
}
private void CopyTile(BitmapTile tile)
{
var width = WmtsTileMatrix.TileWidth;
var height = WmtsTileMatrix.TileHeight;
var x = width * (tile.X - TileMatrix.XMin);
var y = height * (tile.Y - TileMatrix.YMin);
bitmap.WritePixels(new Int32Rect(x, y, width, height), tile.PixelBuffer, 4 * WmtsTileMatrix.TileWidth, 0);
}
private void OnTileCompleted(object sender, EventArgs e)
{
var tile = (BitmapTile)sender;
tile.Completed -= OnTileCompleted;
if (tile.X >= TileMatrix.XMin && tile.X <= TileMatrix.XMax &&
tile.Y >= TileMatrix.YMin && tile.Y <= TileMatrix.YMax &&
tile.PixelBuffer != null)
{ {
var width = WmtsTileMatrix.TileWidth; _ = Dispatcher.InvokeAsync(() => CopyTile(tile));
var height = WmtsTileMatrix.TileHeight;
var x = width * (tile.X - TileMatrix.XMin);
var y = height * (tile.Y - TileMatrix.YMin);
bitmap.WritePixels(new Int32Rect(x, y, width, height), tile.PixelBuffer, 4 * WmtsTileMatrix.TileWidth, 0);
}
private void OnTileCompleted(object sender, EventArgs e)
{
var tile = (BitmapTile)sender;
tile.Completed -= OnTileCompleted;
if (tile.X >= TileMatrix.XMin && tile.X <= TileMatrix.XMax &&
tile.Y >= TileMatrix.YMin && tile.Y <= TileMatrix.YMax &&
tile.PixelBuffer != null)
{
_ = Dispatcher.InvokeAsync(() => CopyTile(tile));
}
} }
} }
} }

View file

@ -1,128 +1,127 @@
using System; using System;
using System.Windows; using System.Windows;
namespace MapControl namespace MapControl;
public static class DependencyPropertyHelper
{ {
public static class DependencyPropertyHelper public static DependencyProperty RegisterAttached<TValue>(
string name,
Type ownerType,
TValue defaultValue = default,
Action<FrameworkElement, TValue, TValue> changed = null,
bool inherits = false)
{ {
public static DependencyProperty RegisterAttached<TValue>( var metadata = new FrameworkPropertyMetadata
string name,
Type ownerType,
TValue defaultValue = default,
Action<FrameworkElement, TValue, TValue> changed = null,
bool inherits = false)
{ {
var metadata = new FrameworkPropertyMetadata DefaultValue = defaultValue,
{ Inherits = inherits
DefaultValue = defaultValue, };
Inherits = inherits
};
if (changed != null) if (changed != null)
{
metadata.PropertyChangedCallback = (o, e) =>
{ {
metadata.PropertyChangedCallback = (o, e) => if (o is FrameworkElement element)
{ {
if (o is FrameworkElement element) changed(element, (TValue)e.OldValue, (TValue)e.NewValue);
{ }
changed(element, (TValue)e.OldValue, (TValue)e.NewValue);
}
};
}
return DependencyProperty.RegisterAttached(name, typeof(TValue), ownerType, metadata);
}
public static DependencyProperty RegisterAttached<TValue>(
string name,
Type ownerType,
TValue defaultValue,
FrameworkPropertyMetadataOptions options)
{
var metadata = new FrameworkPropertyMetadata(defaultValue, options);
return DependencyProperty.RegisterAttached(name, typeof(TValue), ownerType, metadata);
}
public static DependencyProperty Register<TOwner, TValue>(
string name,
TValue defaultValue,
FrameworkPropertyMetadataOptions options)
where TOwner : DependencyObject
{
var metadata = new FrameworkPropertyMetadata(defaultValue, options);
return DependencyProperty.Register(name, typeof(TValue), typeof(TOwner), metadata);
}
public static DependencyProperty Register<TOwner, TValue>(
string name,
TValue defaultValue = default,
Action<TOwner, TValue, TValue> changed = null,
Func<TOwner, TValue, TValue> coerce = null,
bool bindTwoWayByDefault = false)
where TOwner : DependencyObject
{
var metadata = new FrameworkPropertyMetadata
{
DefaultValue = defaultValue,
BindsTwoWayByDefault = bindTwoWayByDefault
}; };
if (changed != null)
{
metadata.PropertyChangedCallback = (o, e) => changed((TOwner)o, (TValue)e.OldValue, (TValue)e.NewValue);
}
if (coerce != null)
{
metadata.CoerceValueCallback = (o, v) => coerce((TOwner)o, (TValue)v);
}
return DependencyProperty.Register(name, typeof(TValue), typeof(TOwner), metadata);
} }
public static DependencyPropertyKey RegisterReadOnly<TOwner, TValue>( return DependencyProperty.RegisterAttached(name, typeof(TValue), ownerType, metadata);
string name, }
TValue defaultValue = default)
where TOwner : DependencyObject public static DependencyProperty RegisterAttached<TValue>(
string name,
Type ownerType,
TValue defaultValue,
FrameworkPropertyMetadataOptions options)
{
var metadata = new FrameworkPropertyMetadata(defaultValue, options);
return DependencyProperty.RegisterAttached(name, typeof(TValue), ownerType, metadata);
}
public static DependencyProperty Register<TOwner, TValue>(
string name,
TValue defaultValue,
FrameworkPropertyMetadataOptions options)
where TOwner : DependencyObject
{
var metadata = new FrameworkPropertyMetadata(defaultValue, options);
return DependencyProperty.Register(name, typeof(TValue), typeof(TOwner), metadata);
}
public static DependencyProperty Register<TOwner, TValue>(
string name,
TValue defaultValue = default,
Action<TOwner, TValue, TValue> changed = null,
Func<TOwner, TValue, TValue> coerce = null,
bool bindTwoWayByDefault = false)
where TOwner : DependencyObject
{
var metadata = new FrameworkPropertyMetadata
{ {
return DependencyProperty.RegisterReadOnly(name, typeof(TValue), typeof(TOwner), new PropertyMetadata(defaultValue)); DefaultValue = defaultValue,
BindsTwoWayByDefault = bindTwoWayByDefault
};
if (changed != null)
{
metadata.PropertyChangedCallback = (o, e) => changed((TOwner)o, (TValue)e.OldValue, (TValue)e.NewValue);
} }
public static DependencyProperty AddOwner<TOwner, TValue>( if (coerce != null)
DependencyProperty source) where TOwner : DependencyObject
{ {
return source.AddOwner(typeof(TOwner)); metadata.CoerceValueCallback = (o, v) => coerce((TOwner)o, (TValue)v);
} }
public static DependencyProperty AddOwner<TOwner, TValue>( return DependencyProperty.Register(name, typeof(TValue), typeof(TOwner), metadata);
DependencyProperty source, }
TValue defaultValue) where TOwner : DependencyObject
{
return source.AddOwner(typeof(TOwner), new FrameworkPropertyMetadata(defaultValue));
}
public static DependencyProperty AddOwner<TOwner, TValue>( public static DependencyPropertyKey RegisterReadOnly<TOwner, TValue>(
DependencyProperty source, string name,
Action<TOwner, TValue, TValue> changed) where TOwner : DependencyObject TValue defaultValue = default)
{ where TOwner : DependencyObject
return source.AddOwner(typeof(TOwner), new FrameworkPropertyMetadata( {
(o, e) => changed((TOwner)o, (TValue)e.OldValue, (TValue)e.NewValue))); return DependencyProperty.RegisterReadOnly(name, typeof(TValue), typeof(TOwner), new PropertyMetadata(defaultValue));
} }
public static DependencyProperty AddOwner<TOwner, TValue>( public static DependencyProperty AddOwner<TOwner, TValue>(
string _, // for compatibility with WinUI/UWP DependencyPropertyHelper DependencyProperty source) where TOwner : DependencyObject
DependencyProperty source) where TOwner : DependencyObject {
{ return source.AddOwner(typeof(TOwner));
return AddOwner<TOwner, TValue>(source); }
}
public static DependencyProperty AddOwner<TOwner, TValue>( public static DependencyProperty AddOwner<TOwner, TValue>(
string _, // for compatibility with WinUI/UWP DependencyPropertyHelper DependencyProperty source,
DependencyProperty source, TValue defaultValue) where TOwner : DependencyObject
Action<TOwner, TValue, TValue> changed) where TOwner : DependencyObject {
{ return source.AddOwner(typeof(TOwner), new FrameworkPropertyMetadata(defaultValue));
return AddOwner(source, changed); }
}
public static DependencyProperty AddOwner<TOwner, TValue>(
DependencyProperty source,
Action<TOwner, TValue, TValue> changed) where TOwner : DependencyObject
{
return source.AddOwner(typeof(TOwner), new FrameworkPropertyMetadata(
(o, e) => changed((TOwner)o, (TValue)e.OldValue, (TValue)e.NewValue)));
}
public static DependencyProperty AddOwner<TOwner, TValue>(
string _, // for compatibility with WinUI/UWP DependencyPropertyHelper
DependencyProperty source) where TOwner : DependencyObject
{
return AddOwner<TOwner, TValue>(source);
}
public static DependencyProperty AddOwner<TOwner, TValue>(
string _, // for compatibility with WinUI/UWP DependencyPropertyHelper
DependencyProperty source,
Action<TOwner, TValue, TValue> changed) where TOwner : DependencyObject
{
return AddOwner(source, changed);
} }
} }

View file

@ -4,75 +4,74 @@ using System.Windows.Media;
using System.Windows.Media.Animation; using System.Windows.Media.Animation;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
namespace MapControl namespace MapControl;
public class DrawingTile : Tile
{ {
public class DrawingTile : Tile public DrawingTile(int zoomLevel, int x, int y, int columnCount)
: base(zoomLevel, x, y, columnCount)
{ {
public DrawingTile(int zoomLevel, int x, int y, int columnCount) Drawing.Children.Add(ImageDrawing);
: base(zoomLevel, x, y, columnCount) }
public DrawingGroup Drawing { get; } = new DrawingGroup();
public ImageDrawing ImageDrawing { get; } = new ImageDrawing();
public override async Task LoadImageAsync(Func<Task<ImageSource>> loadImageFunc)
{
var image = await loadImageFunc().ConfigureAwait(false);
void SetImageSource()
{ {
Drawing.Children.Add(ImageDrawing); ImageDrawing.ImageSource = image;
}
public DrawingGroup Drawing { get; } = new DrawingGroup(); if (image != null && MapBase.ImageFadeDuration > TimeSpan.Zero)
public ImageDrawing ImageDrawing { get; } = new ImageDrawing();
public override async Task LoadImageAsync(Func<Task<ImageSource>> loadImageFunc)
{
var image = await loadImageFunc().ConfigureAwait(false);
void SetImageSource()
{ {
ImageDrawing.ImageSource = image; if (image is BitmapSource bitmap && !bitmap.IsFrozen && bitmap.IsDownloading)
if (image != null && MapBase.ImageFadeDuration > TimeSpan.Zero)
{ {
if (image is BitmapSource bitmap && !bitmap.IsFrozen && bitmap.IsDownloading) bitmap.DownloadCompleted += BitmapDownloadCompleted;
{ bitmap.DownloadFailed += BitmapDownloadFailed;
bitmap.DownloadCompleted += BitmapDownloadCompleted; }
bitmap.DownloadFailed += BitmapDownloadFailed; else
} {
else BeginFadeInAnimation();
{
BeginFadeInAnimation();
}
} }
} }
await Drawing.Dispatcher.InvokeAsync(SetImageSource);
} }
private void BeginFadeInAnimation() await Drawing.Dispatcher.InvokeAsync(SetImageSource);
}
private void BeginFadeInAnimation()
{
var fadeInAnimation = new DoubleAnimation
{ {
var fadeInAnimation = new DoubleAnimation From = 0d,
{ Duration = MapBase.ImageFadeDuration,
From = 0d, FillBehavior = FillBehavior.Stop
Duration = MapBase.ImageFadeDuration, };
FillBehavior = FillBehavior.Stop
};
Drawing.BeginAnimation(DrawingGroup.OpacityProperty, fadeInAnimation); Drawing.BeginAnimation(DrawingGroup.OpacityProperty, fadeInAnimation);
} }
private void BitmapDownloadCompleted(object sender, EventArgs e) private void BitmapDownloadCompleted(object sender, EventArgs e)
{ {
var bitmap = (BitmapSource)sender; var bitmap = (BitmapSource)sender;
bitmap.DownloadCompleted -= BitmapDownloadCompleted; bitmap.DownloadCompleted -= BitmapDownloadCompleted;
bitmap.DownloadFailed -= BitmapDownloadFailed; bitmap.DownloadFailed -= BitmapDownloadFailed;
BeginFadeInAnimation(); BeginFadeInAnimation();
} }
private void BitmapDownloadFailed(object sender, ExceptionEventArgs e) private void BitmapDownloadFailed(object sender, ExceptionEventArgs e)
{ {
var bitmap = (BitmapSource)sender; var bitmap = (BitmapSource)sender;
bitmap.DownloadCompleted -= BitmapDownloadCompleted; bitmap.DownloadCompleted -= BitmapDownloadCompleted;
bitmap.DownloadFailed -= BitmapDownloadFailed; bitmap.DownloadFailed -= BitmapDownloadFailed;
ImageDrawing.ImageSource = null; ImageDrawing.ImageSource = null;
}
} }
} }

View file

@ -4,113 +4,112 @@ using System.Linq;
using System.Windows; using System.Windows;
using System.Windows.Media; using System.Windows.Media;
namespace MapControl namespace MapControl;
public class DrawingTileMatrixLayer(WmtsTileMatrix wmtsTileMatrix, int zoomLevel) : UIElement
{ {
public class DrawingTileMatrixLayer(WmtsTileMatrix wmtsTileMatrix, int zoomLevel) : UIElement private readonly MatrixTransform transform = new MatrixTransform();
public WmtsTileMatrix WmtsTileMatrix => wmtsTileMatrix;
public TileMatrix TileMatrix { get; private set; } = new TileMatrix(zoomLevel, 1, 1, 0, 0);
public IEnumerable<DrawingTile> Tiles { get; private set; } = [];
protected override void OnRender(DrawingContext drawingContext)
{ {
private readonly MatrixTransform transform = new MatrixTransform(); drawingContext.PushTransform(transform);
public WmtsTileMatrix WmtsTileMatrix => wmtsTileMatrix; foreach (var tile in Tiles)
public TileMatrix TileMatrix { get; private set; } = new TileMatrix(zoomLevel, 1, 1, 0, 0);
public IEnumerable<DrawingTile> Tiles { get; private set; } = [];
protected override void OnRender(DrawingContext drawingContext)
{ {
drawingContext.PushTransform(transform); drawingContext.DrawDrawing(tile.Drawing);
foreach (var tile in Tiles)
{
drawingContext.DrawDrawing(tile.Drawing);
}
}
public void UpdateRenderTransform(ViewTransform viewTransform)
{
// Tile matrix origin in pixels.
//
var tileMatrixOrigin = new Point(WmtsTileMatrix.TileWidth * TileMatrix.XMin, WmtsTileMatrix.TileHeight * TileMatrix.YMin);
transform.Matrix = viewTransform.GetTileLayerTransform(WmtsTileMatrix.Scale, WmtsTileMatrix.TopLeft, tileMatrixOrigin);
}
public bool UpdateTiles(ViewTransform viewTransform, double viewWidth, double viewHeight)
{
// Tile matrix bounds in pixels.
//
var bounds = viewTransform.GetTileMatrixBounds(WmtsTileMatrix.Scale, WmtsTileMatrix.TopLeft, viewWidth, viewHeight);
// Tile X and Y bounds.
//
var xMin = (int)Math.Floor(bounds.X / WmtsTileMatrix.TileWidth);
var yMin = (int)Math.Floor(bounds.Y / WmtsTileMatrix.TileHeight);
var xMax = (int)Math.Floor((bounds.X + bounds.Width) / WmtsTileMatrix.TileWidth);
var yMax = (int)Math.Floor((bounds.Y + bounds.Height) / WmtsTileMatrix.TileHeight);
if (!WmtsTileMatrix.HasFullHorizontalCoverage)
{
// Set X range limits.
//
xMin = Math.Max(xMin, 0);
xMax = Math.Min(Math.Max(xMax, 0), WmtsTileMatrix.MatrixWidth - 1);
}
// Set Y range limits.
//
yMin = Math.Max(yMin, 0);
yMax = Math.Min(Math.Max(yMax, 0), WmtsTileMatrix.MatrixHeight - 1);
if (TileMatrix.XMin == xMin && TileMatrix.YMin == yMin &&
TileMatrix.XMax == xMax && TileMatrix.YMax == yMax)
{
// No change of the TileMatrix and the Tiles collection.
//
return false;
}
TileMatrix = new TileMatrix(TileMatrix.ZoomLevel, xMin, yMin, xMax, yMax);
CreateTiles();
InvalidateVisual();
return true;
}
private void CreateTiles()
{
var tiles = new List<DrawingTile>(TileMatrix.Width * TileMatrix.Height);
for (var y = TileMatrix.YMin; y <= TileMatrix.YMax; y++)
{
for (var x = TileMatrix.XMin; x <= TileMatrix.XMax; x++)
{
var tile = Tiles.FirstOrDefault(t => t.X == x && t.Y == y);
if (tile == null)
{
tile = new DrawingTile(TileMatrix.ZoomLevel, x, y, WmtsTileMatrix.MatrixWidth);
var equivalentTile = Tiles.FirstOrDefault(t => t.ImageDrawing.ImageSource != null && t.Column == tile.Column && t.Row == tile.Row);
if (equivalentTile != null)
{
tile.IsPending = false;
tile.ImageDrawing.ImageSource = equivalentTile.ImageDrawing.ImageSource; // no Opacity animation
}
}
tile.ImageDrawing.Rect = new Rect(
WmtsTileMatrix.TileWidth * (x - TileMatrix.XMin),
WmtsTileMatrix.TileHeight * (y - TileMatrix.YMin),
WmtsTileMatrix.TileWidth,
WmtsTileMatrix.TileHeight);
tiles.Add(tile);
}
}
Tiles = tiles;
} }
} }
public void UpdateRenderTransform(ViewTransform viewTransform)
{
// Tile matrix origin in pixels.
//
var tileMatrixOrigin = new Point(WmtsTileMatrix.TileWidth * TileMatrix.XMin, WmtsTileMatrix.TileHeight * TileMatrix.YMin);
transform.Matrix = viewTransform.GetTileLayerTransform(WmtsTileMatrix.Scale, WmtsTileMatrix.TopLeft, tileMatrixOrigin);
}
public bool UpdateTiles(ViewTransform viewTransform, double viewWidth, double viewHeight)
{
// Tile matrix bounds in pixels.
//
var bounds = viewTransform.GetTileMatrixBounds(WmtsTileMatrix.Scale, WmtsTileMatrix.TopLeft, viewWidth, viewHeight);
// Tile X and Y bounds.
//
var xMin = (int)Math.Floor(bounds.X / WmtsTileMatrix.TileWidth);
var yMin = (int)Math.Floor(bounds.Y / WmtsTileMatrix.TileHeight);
var xMax = (int)Math.Floor((bounds.X + bounds.Width) / WmtsTileMatrix.TileWidth);
var yMax = (int)Math.Floor((bounds.Y + bounds.Height) / WmtsTileMatrix.TileHeight);
if (!WmtsTileMatrix.HasFullHorizontalCoverage)
{
// Set X range limits.
//
xMin = Math.Max(xMin, 0);
xMax = Math.Min(Math.Max(xMax, 0), WmtsTileMatrix.MatrixWidth - 1);
}
// Set Y range limits.
//
yMin = Math.Max(yMin, 0);
yMax = Math.Min(Math.Max(yMax, 0), WmtsTileMatrix.MatrixHeight - 1);
if (TileMatrix.XMin == xMin && TileMatrix.YMin == yMin &&
TileMatrix.XMax == xMax && TileMatrix.YMax == yMax)
{
// No change of the TileMatrix and the Tiles collection.
//
return false;
}
TileMatrix = new TileMatrix(TileMatrix.ZoomLevel, xMin, yMin, xMax, yMax);
CreateTiles();
InvalidateVisual();
return true;
}
private void CreateTiles()
{
var tiles = new List<DrawingTile>(TileMatrix.Width * TileMatrix.Height);
for (var y = TileMatrix.YMin; y <= TileMatrix.YMax; y++)
{
for (var x = TileMatrix.XMin; x <= TileMatrix.XMax; x++)
{
var tile = Tiles.FirstOrDefault(t => t.X == x && t.Y == y);
if (tile == null)
{
tile = new DrawingTile(TileMatrix.ZoomLevel, x, y, WmtsTileMatrix.MatrixWidth);
var equivalentTile = Tiles.FirstOrDefault(t => t.ImageDrawing.ImageSource != null && t.Column == tile.Column && t.Row == tile.Row);
if (equivalentTile != null)
{
tile.IsPending = false;
tile.ImageDrawing.ImageSource = equivalentTile.ImageDrawing.ImageSource; // no Opacity animation
}
}
tile.ImageDrawing.Rect = new Rect(
WmtsTileMatrix.TileWidth * (x - TileMatrix.XMin),
WmtsTileMatrix.TileHeight * (y - TileMatrix.YMin),
WmtsTileMatrix.TileWidth,
WmtsTileMatrix.TileHeight);
tiles.Add(tile);
}
}
Tiles = tiles;
}
} }

View file

@ -5,113 +5,112 @@ using System.Threading.Tasks;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
namespace MapControl namespace MapControl;
public static partial class GeoImage
{ {
public static partial class GeoImage private static Task<GeoBitmap> LoadGeoTiff(string sourcePath)
{ {
private static Task<GeoBitmap> LoadGeoTiff(string sourcePath) return Task.Run(() =>
{ {
return Task.Run(() => BitmapSource bitmap;
Matrix transform;
MapProjection projection = null;
using (var stream = File.OpenRead(sourcePath))
{ {
BitmapSource bitmap; bitmap = BitmapFrame.Create(stream, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
Matrix transform; }
MapProjection projection = null;
using (var stream = File.OpenRead(sourcePath)) var metadata = (BitmapMetadata)bitmap.Metadata;
{
bitmap = BitmapFrame.Create(stream, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
}
var metadata = (BitmapMetadata)bitmap.Metadata; if (metadata.GetQuery(QueryString(ModelPixelScaleTag)) is double[] pixelScale &&
pixelScale.Length == 3 &&
metadata.GetQuery(QueryString(ModelTiePointTag)) is double[] tiePoint &&
tiePoint.Length >= 6)
{
transform = new Matrix(pixelScale[0], 0d, 0d, -pixelScale[1], tiePoint[3], tiePoint[4]);
}
else if (metadata.GetQuery(QueryString(ModelTransformationTag)) is double[] transformValues &&
transformValues.Length == 16)
{
transform = new Matrix(transformValues[0], transformValues[1],
transformValues[4], transformValues[5],
transformValues[3], transformValues[7]);
}
else
{
throw new ArgumentException("No coordinate transformation found.");
}
if (metadata.GetQuery(QueryString(ModelPixelScaleTag)) is double[] pixelScale && if (metadata.GetQuery(QueryString(GeoKeyDirectoryTag)) is short[] geoKeyDirectory)
pixelScale.Length == 3 && {
metadata.GetQuery(QueryString(ModelTiePointTag)) is double[] tiePoint && projection = GetProjection(geoKeyDirectory);
tiePoint.Length >= 6) }
{
transform = new Matrix(pixelScale[0], 0d, 0d, -pixelScale[1], tiePoint[3], tiePoint[4]);
}
else if (metadata.GetQuery(QueryString(ModelTransformationTag)) is double[] transformValues &&
transformValues.Length == 16)
{
transform = new Matrix(transformValues[0], transformValues[1],
transformValues[4], transformValues[5],
transformValues[3], transformValues[7]);
}
else
{
throw new ArgumentException("No coordinate transformation found.");
}
if (metadata.GetQuery(QueryString(GeoKeyDirectoryTag)) is short[] geoKeyDirectory) if (metadata.GetQuery(QueryString(NoDataTag)) is string noData &&
{ int.TryParse(noData, out int noDataValue))
projection = GetProjection(geoKeyDirectory); {
} bitmap = ConvertTransparentPixel(bitmap, noDataValue);
}
if (metadata.GetQuery(QueryString(NoDataTag)) is string noData && return new GeoBitmap(bitmap, transform, projection);
int.TryParse(noData, out int noDataValue)) });
{ }
bitmap = ConvertTransparentPixel(bitmap, noDataValue);
}
return new GeoBitmap(bitmap, transform, projection); private static BitmapSource ConvertTransparentPixel(BitmapSource source, int transparentPixel)
}); {
BitmapPalette sourcePalette = null;
var targetFormat = source.Format;
if (source.Format == PixelFormats.Indexed8 ||
source.Format == PixelFormats.Indexed4 ||
source.Format == PixelFormats.Indexed2 ||
source.Format == PixelFormats.Indexed1)
{
sourcePalette = source.Palette;
}
else if (source.Format == PixelFormats.Gray8)
{
sourcePalette = BitmapPalettes.Gray256;
targetFormat = PixelFormats.Indexed8;
}
else if (source.Format == PixelFormats.Gray4)
{
sourcePalette = BitmapPalettes.Gray16;
targetFormat = PixelFormats.Indexed4;
}
else if (source.Format == PixelFormats.Gray2)
{
sourcePalette = BitmapPalettes.Gray4;
targetFormat = PixelFormats.Indexed2;
}
else if (source.Format == PixelFormats.BlackWhite)
{
sourcePalette = BitmapPalettes.BlackAndWhite;
targetFormat = PixelFormats.Indexed1;
} }
private static BitmapSource ConvertTransparentPixel(BitmapSource source, int transparentPixel) if (sourcePalette == null || transparentPixel >= sourcePalette.Colors.Count)
{ {
BitmapPalette sourcePalette = null; return source;
var targetFormat = source.Format;
if (source.Format == PixelFormats.Indexed8 ||
source.Format == PixelFormats.Indexed4 ||
source.Format == PixelFormats.Indexed2 ||
source.Format == PixelFormats.Indexed1)
{
sourcePalette = source.Palette;
}
else if (source.Format == PixelFormats.Gray8)
{
sourcePalette = BitmapPalettes.Gray256;
targetFormat = PixelFormats.Indexed8;
}
else if (source.Format == PixelFormats.Gray4)
{
sourcePalette = BitmapPalettes.Gray16;
targetFormat = PixelFormats.Indexed4;
}
else if (source.Format == PixelFormats.Gray2)
{
sourcePalette = BitmapPalettes.Gray4;
targetFormat = PixelFormats.Indexed2;
}
else if (source.Format == PixelFormats.BlackWhite)
{
sourcePalette = BitmapPalettes.BlackAndWhite;
targetFormat = PixelFormats.Indexed1;
}
if (sourcePalette == null || transparentPixel >= sourcePalette.Colors.Count)
{
return source;
}
var colors = sourcePalette.Colors.ToList();
colors[transparentPixel] = Colors.Transparent;
var stride = (source.PixelWidth * source.Format.BitsPerPixel + 7) / 8;
var buffer = new byte[stride * source.PixelHeight];
source.CopyPixels(buffer, stride, 0);
var target = BitmapSource.Create(
source.PixelWidth, source.PixelHeight, source.DpiX, source.DpiY,
targetFormat, new BitmapPalette(colors), buffer, stride);
target.Freeze();
return target;
} }
var colors = sourcePalette.Colors.ToList();
colors[transparentPixel] = Colors.Transparent;
var stride = (source.PixelWidth * source.Format.BitsPerPixel + 7) / 8;
var buffer = new byte[stride * source.PixelHeight];
source.CopyPixels(buffer, stride, 0);
var target = BitmapSource.Create(
source.PixelWidth, source.PixelHeight, source.DpiX, source.DpiY,
targetFormat, new BitmapPalette(colors), buffer, stride);
target.Freeze();
return target;
} }
} }

View file

@ -6,92 +6,91 @@ using System.Windows;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
namespace MapControl namespace MapControl;
public static partial class ImageLoader
{ {
public static partial class ImageLoader public static ImageSource LoadResourceImage(Uri uri)
{ {
public static ImageSource LoadResourceImage(Uri uri) return new BitmapImage(uri);
}
public static ImageSource LoadImage(Stream stream)
{
var image = new BitmapImage();
image.BeginInit();
image.CacheOption = BitmapCacheOption.OnLoad;
image.StreamSource = stream;
image.EndInit();
image.Freeze();
return image;
}
public static ImageSource LoadImage(string path)
{
ImageSource image = null;
if (File.Exists(path))
{ {
return new BitmapImage(uri); using var stream = File.OpenRead(path);
image = LoadImage(stream);
} }
public static ImageSource LoadImage(Stream stream) return image;
}
public static Task<ImageSource> LoadImageAsync(Stream stream)
{
return Thread.CurrentThread.IsThreadPoolThread ?
Task.FromResult(LoadImage(stream)) :
Task.Run(() => LoadImage(stream));
}
public static Task<ImageSource> LoadImageAsync(string path)
{
return Thread.CurrentThread.IsThreadPoolThread ?
Task.FromResult(LoadImage(path)) :
Task.Run(() => LoadImage(path));
}
internal static async Task<ImageSource> LoadMergedImageAsync(Uri uri1, Uri uri2, IProgress<double> progress)
{
WriteableBitmap mergedBitmap = null;
var p1 = 0d;
var p2 = 0d;
var images = await Task.WhenAll(
LoadImageAsync(uri1, new Progress<double>(p => { p1 = p; progress.Report((p1 + p2) / 2d); })),
LoadImageAsync(uri2, new Progress<double>(p => { p2 = p; progress.Report((p1 + p2) / 2d); })));
if (images.Length == 2 &&
images[0] is BitmapSource bitmap1 &&
images[1] is BitmapSource bitmap2 &&
bitmap1.PixelHeight == bitmap2.PixelHeight &&
bitmap1.Format == bitmap2.Format &&
bitmap1.Format.BitsPerPixel % 8 == 0)
{ {
var image = new BitmapImage(); var format = bitmap1.Format;
var height = bitmap1.PixelHeight;
var width1 = bitmap1.PixelWidth;
var width2 = bitmap2.PixelWidth;
var stride1 = width1 * format.BitsPerPixel / 8;
var stride2 = width2 * format.BitsPerPixel / 8;
var buffer1 = new byte[stride1 * height];
var buffer2 = new byte[stride2 * height];
image.BeginInit(); bitmap1.CopyPixels(buffer1, stride1, 0);
image.CacheOption = BitmapCacheOption.OnLoad; bitmap2.CopyPixels(buffer2, stride2, 0);
image.StreamSource = stream;
image.EndInit();
image.Freeze();
return image; mergedBitmap = new WriteableBitmap(width1 + width2, height, 96, 96, format, null);
mergedBitmap.WritePixels(new Int32Rect(0, 0, width1, height), buffer1, stride1, 0);
mergedBitmap.WritePixels(new Int32Rect(width1, 0, width2, height), buffer2, stride2, 0);
mergedBitmap.Freeze();
} }
public static ImageSource LoadImage(string path) return mergedBitmap;
{
ImageSource image = null;
if (File.Exists(path))
{
using var stream = File.OpenRead(path);
image = LoadImage(stream);
}
return image;
}
public static Task<ImageSource> LoadImageAsync(Stream stream)
{
return Thread.CurrentThread.IsThreadPoolThread ?
Task.FromResult(LoadImage(stream)) :
Task.Run(() => LoadImage(stream));
}
public static Task<ImageSource> LoadImageAsync(string path)
{
return Thread.CurrentThread.IsThreadPoolThread ?
Task.FromResult(LoadImage(path)) :
Task.Run(() => LoadImage(path));
}
internal static async Task<ImageSource> LoadMergedImageAsync(Uri uri1, Uri uri2, IProgress<double> progress)
{
WriteableBitmap mergedBitmap = null;
var p1 = 0d;
var p2 = 0d;
var images = await Task.WhenAll(
LoadImageAsync(uri1, new Progress<double>(p => { p1 = p; progress.Report((p1 + p2) / 2d); })),
LoadImageAsync(uri2, new Progress<double>(p => { p2 = p; progress.Report((p1 + p2) / 2d); })));
if (images.Length == 2 &&
images[0] is BitmapSource bitmap1 &&
images[1] is BitmapSource bitmap2 &&
bitmap1.PixelHeight == bitmap2.PixelHeight &&
bitmap1.Format == bitmap2.Format &&
bitmap1.Format.BitsPerPixel % 8 == 0)
{
var format = bitmap1.Format;
var height = bitmap1.PixelHeight;
var width1 = bitmap1.PixelWidth;
var width2 = bitmap2.PixelWidth;
var stride1 = width1 * format.BitsPerPixel / 8;
var stride2 = width2 * format.BitsPerPixel / 8;
var buffer1 = new byte[stride1 * height];
var buffer2 = new byte[stride2 * height];
bitmap1.CopyPixels(buffer1, stride1, 0);
bitmap2.CopyPixels(buffer2, stride2, 0);
mergedBitmap = new WriteableBitmap(width1 + width2, height, 96, 96, format, null);
mergedBitmap.WritePixels(new Int32Rect(0, 0, width1, height), buffer1, stride1, 0);
mergedBitmap.WritePixels(new Int32Rect(width1, 0, width2, height), buffer2, stride2, 0);
mergedBitmap.Freeze();
}
return mergedBitmap;
}
} }
} }

View file

@ -6,68 +6,67 @@ using System.Windows.Media;
using System.Windows.Media.Animation; using System.Windows.Media.Animation;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
namespace MapControl namespace MapControl;
public class ImageTile(int zoomLevel, int x, int y, int columnCount)
: Tile(zoomLevel, x, y, columnCount)
{ {
public class ImageTile(int zoomLevel, int x, int y, int columnCount) public Image Image { get; } = new Image { Stretch = Stretch.Fill };
: Tile(zoomLevel, x, y, columnCount)
public override async Task LoadImageAsync(Func<Task<ImageSource>> loadImageFunc)
{ {
public Image Image { get; } = new Image { Stretch = Stretch.Fill }; var image = await loadImageFunc().ConfigureAwait(false);
public override async Task LoadImageAsync(Func<Task<ImageSource>> loadImageFunc) void SetImageSource()
{ {
var image = await loadImageFunc().ConfigureAwait(false); Image.Source = image;
void SetImageSource() if (image != null && MapBase.ImageFadeDuration > TimeSpan.Zero)
{ {
Image.Source = image; if (image is BitmapSource bitmap && !bitmap.IsFrozen && bitmap.IsDownloading)
if (image != null && MapBase.ImageFadeDuration > TimeSpan.Zero)
{ {
if (image is BitmapSource bitmap && !bitmap.IsFrozen && bitmap.IsDownloading) bitmap.DownloadCompleted += BitmapDownloadCompleted;
{ bitmap.DownloadFailed += BitmapDownloadFailed;
bitmap.DownloadCompleted += BitmapDownloadCompleted; }
bitmap.DownloadFailed += BitmapDownloadFailed; else
} {
else BeginFadeInAnimation();
{
BeginFadeInAnimation();
}
} }
} }
await Image.Dispatcher.InvokeAsync(SetImageSource);
} }
private void BeginFadeInAnimation() await Image.Dispatcher.InvokeAsync(SetImageSource);
}
private void BeginFadeInAnimation()
{
var fadeInAnimation = new DoubleAnimation
{ {
var fadeInAnimation = new DoubleAnimation From = 0d,
{ Duration = MapBase.ImageFadeDuration,
From = 0d, FillBehavior = FillBehavior.Stop
Duration = MapBase.ImageFadeDuration, };
FillBehavior = FillBehavior.Stop
};
Image.BeginAnimation(UIElement.OpacityProperty, fadeInAnimation); Image.BeginAnimation(UIElement.OpacityProperty, fadeInAnimation);
} }
private void BitmapDownloadCompleted(object sender, EventArgs e) private void BitmapDownloadCompleted(object sender, EventArgs e)
{ {
var bitmap = (BitmapSource)sender; var bitmap = (BitmapSource)sender;
bitmap.DownloadCompleted -= BitmapDownloadCompleted; bitmap.DownloadCompleted -= BitmapDownloadCompleted;
bitmap.DownloadFailed -= BitmapDownloadFailed; bitmap.DownloadFailed -= BitmapDownloadFailed;
BeginFadeInAnimation(); BeginFadeInAnimation();
} }
private void BitmapDownloadFailed(object sender, ExceptionEventArgs e) private void BitmapDownloadFailed(object sender, ExceptionEventArgs e)
{ {
var bitmap = (BitmapSource)sender; var bitmap = (BitmapSource)sender;
bitmap.DownloadCompleted -= BitmapDownloadCompleted; bitmap.DownloadCompleted -= BitmapDownloadCompleted;
bitmap.DownloadFailed -= BitmapDownloadFailed; bitmap.DownloadFailed -= BitmapDownloadFailed;
Image.Source = null; Image.Source = null;
}
} }
} }

View file

@ -2,38 +2,37 @@
using System.Windows; using System.Windows;
using System.Windows.Media.Animation; using System.Windows.Media.Animation;
namespace MapControl namespace MapControl;
public class LocationAnimation : AnimationTimeline
{ {
public class LocationAnimation : AnimationTimeline public override Type TargetPropertyType => typeof(Location);
public Location To { get; set; }
public IEasingFunction EasingFunction { get; set; }
protected override Freezable CreateInstanceCore()
{ {
public override Type TargetPropertyType => typeof(Location); return new LocationAnimation
public Location To { get; set; }
public IEasingFunction EasingFunction { get; set; }
protected override Freezable CreateInstanceCore()
{ {
return new LocationAnimation To = To,
{ EasingFunction = EasingFunction
To = To, };
EasingFunction = EasingFunction }
};
public override object GetCurrentValue(object defaultOriginValue, object defaultDestinationValue, AnimationClock animationClock)
{
var from = (Location)defaultOriginValue;
var progress = animationClock.CurrentProgress ?? 1d;
if (EasingFunction != null)
{
progress = EasingFunction.Ease(progress);
} }
public override object GetCurrentValue(object defaultOriginValue, object defaultDestinationValue, AnimationClock animationClock) return new Location(
{ (1d - progress) * from.Latitude + progress * To.Latitude,
var from = (Location)defaultOriginValue; (1d - progress) * from.Longitude + progress * To.Longitude);
var progress = animationClock.CurrentProgress ?? 1d;
if (EasingFunction != null)
{
progress = EasingFunction.Ease(progress);
}
return new Location(
(1d - progress) * from.Latitude + progress * To.Latitude,
(1d - progress) * from.Longitude + progress * To.Longitude);
}
} }
} }

View file

@ -1,93 +1,92 @@
using System.Windows; using System.Windows;
using System.Windows.Input; using System.Windows.Input;
namespace MapControl namespace MapControl;
public partial class Map
{ {
public partial class Map static Map()
{ {
static Map() IsManipulationEnabledProperty.OverrideMetadata(typeof(Map), new FrameworkPropertyMetadata(true));
}
public static readonly DependencyProperty ManipulationModeProperty =
DependencyPropertyHelper.Register<Map, ManipulationModes>(nameof(ManipulationMode), ManipulationModes.Translate | ManipulationModes.Scale);
/// <summary>
/// Gets or sets a value that specifies how the map control handles manipulations.
/// </summary>
public ManipulationModes ManipulationMode
{
get => (ManipulationModes)GetValue(ManipulationModeProperty);
set => SetValue(ManipulationModeProperty, value);
}
protected override void OnMouseWheel(MouseWheelEventArgs e)
{
base.OnMouseWheel(e);
// Standard mouse wheel delta value is 120.
//
OnMouseWheel(e.GetPosition(this), e.Delta / 120d);
}
private Point? mousePosition;
protected override void OnPreviewMouseLeftButtonDown(MouseButtonEventArgs e)
{
base.OnPreviewMouseLeftButtonDown(e);
if (Keyboard.Modifiers == ModifierKeys.None)
{ {
IsManipulationEnabledProperty.OverrideMetadata(typeof(Map), new FrameworkPropertyMetadata(true)); // Do not call CaptureMouse here because it avoids MapItem selection.
}
public static readonly DependencyProperty ManipulationModeProperty =
DependencyPropertyHelper.Register<Map, ManipulationModes>(nameof(ManipulationMode), ManipulationModes.Translate | ManipulationModes.Scale);
/// <summary>
/// Gets or sets a value that specifies how the map control handles manipulations.
/// </summary>
public ManipulationModes ManipulationMode
{
get => (ManipulationModes)GetValue(ManipulationModeProperty);
set => SetValue(ManipulationModeProperty, value);
}
protected override void OnMouseWheel(MouseWheelEventArgs e)
{
base.OnMouseWheel(e);
// Standard mouse wheel delta value is 120.
// //
OnMouseWheel(e.GetPosition(this), e.Delta / 120d); mousePosition = e.GetPosition(this);
}
private Point? mousePosition;
protected override void OnPreviewMouseLeftButtonDown(MouseButtonEventArgs e)
{
base.OnPreviewMouseLeftButtonDown(e);
if (Keyboard.Modifiers == ModifierKeys.None)
{
// Do not call CaptureMouse here because it avoids MapItem selection.
//
mousePosition = e.GetPosition(this);
}
}
protected override void OnPreviewMouseLeftButtonUp(MouseButtonEventArgs e)
{
base.OnPreviewMouseLeftButtonUp(e);
if (mousePosition.HasValue)
{
mousePosition = null;
ReleaseMouseCapture();
}
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
if (mousePosition.HasValue)
{
if (!IsMouseCaptured)
{
CaptureMouse();
}
var p = e.GetPosition(this);
TranslateMap(new Point(p.X - mousePosition.Value.X, p.Y - mousePosition.Value.Y));
mousePosition = p;
}
}
protected override void OnManipulationStarted(ManipulationStartedEventArgs e)
{
Manipulation.SetManipulationMode(this, ManipulationMode);
base.OnManipulationStarted(e);
}
protected override void OnManipulationDelta(ManipulationDeltaEventArgs e)
{
base.OnManipulationDelta(e);
TransformMap(e.ManipulationOrigin,
(Point)e.DeltaManipulation.Translation,
e.DeltaManipulation.Rotation,
e.DeltaManipulation.Scale.LengthSquared / 2d);
} }
} }
protected override void OnPreviewMouseLeftButtonUp(MouseButtonEventArgs e)
{
base.OnPreviewMouseLeftButtonUp(e);
if (mousePosition.HasValue)
{
mousePosition = null;
ReleaseMouseCapture();
}
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
if (mousePosition.HasValue)
{
if (!IsMouseCaptured)
{
CaptureMouse();
}
var p = e.GetPosition(this);
TranslateMap(new Point(p.X - mousePosition.Value.X, p.Y - mousePosition.Value.Y));
mousePosition = p;
}
}
protected override void OnManipulationStarted(ManipulationStartedEventArgs e)
{
Manipulation.SetManipulationMode(this, ManipulationMode);
base.OnManipulationStarted(e);
}
protected override void OnManipulationDelta(ManipulationDeltaEventArgs e)
{
base.OnManipulationDelta(e);
TransformMap(e.ManipulationOrigin,
(Point)e.DeltaManipulation.Translation,
e.DeltaManipulation.Rotation,
e.DeltaManipulation.Scale.LengthSquared / 2d);
}
} }

View file

@ -3,278 +3,277 @@ using System.Windows.Documents;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Animation; using System.Windows.Media.Animation;
namespace MapControl namespace MapControl;
public partial class MapBase
{ {
public partial class MapBase public static readonly DependencyProperty ForegroundProperty =
DependencyPropertyHelper.AddOwner<MapBase, Brush>(TextElement.ForegroundProperty, Brushes.Black);
public static readonly DependencyProperty AnimationEasingFunctionProperty =
DependencyPropertyHelper.Register<MapBase, IEasingFunction>(nameof(AnimationEasingFunction),
new QuadraticEase { EasingMode = EasingMode.EaseOut });
public static readonly DependencyProperty CenterProperty =
DependencyPropertyHelper.Register<MapBase, Location>(nameof(Center), new Location(0d, 0d),
(map, oldValue, newValue) => map.CenterPropertyChanged(newValue),
(map, value) => map.CoerceCenterProperty(value),
true);
public static readonly DependencyProperty TargetCenterProperty =
DependencyPropertyHelper.Register<MapBase, Location>(nameof(TargetCenter), new Location(0d, 0d),
(map, oldValue, newValue) => map.TargetCenterPropertyChanged(newValue),
(map, value) => map.CoerceCenterProperty(value),
true);
public static readonly DependencyProperty MinZoomLevelProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(MinZoomLevel), 1d,
(map, oldValue, newValue) => map.MinZoomLevelPropertyChanged(newValue),
(map, value) => map.CoerceMinZoomLevelProperty(value));
public static readonly DependencyProperty MaxZoomLevelProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(MaxZoomLevel), 20d,
(map, oldValue, newValue) => map.MaxZoomLevelPropertyChanged(newValue),
(map, value) => map.CoerceMaxZoomLevelProperty(value));
public static readonly DependencyProperty ZoomLevelProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(ZoomLevel), 1d,
(map, oldValue, newValue) => map.ZoomLevelPropertyChanged(newValue),
(map, value) => map.CoerceZoomLevelProperty(value),
true);
public static readonly DependencyProperty TargetZoomLevelProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(TargetZoomLevel), 1d,
(map, oldValue, newValue) => map.TargetZoomLevelPropertyChanged(newValue),
(map, value) => map.CoerceZoomLevelProperty(value),
true);
public static readonly DependencyProperty HeadingProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(Heading), 0d,
(map, oldValue, newValue) => map.HeadingPropertyChanged(newValue),
(map, value) => map.CoerceHeadingProperty(value),
true);
public static readonly DependencyProperty TargetHeadingProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(TargetHeading), 0d,
(map, oldValue, newValue) => map.TargetHeadingPropertyChanged(newValue),
(map, value) => map.CoerceHeadingProperty(value),
true);
private static readonly DependencyPropertyKey ViewScalePropertyKey =
DependencyPropertyHelper.RegisterReadOnly<MapBase, double>(nameof(ViewScale));
public static readonly DependencyProperty ViewScaleProperty = ViewScalePropertyKey.DependencyProperty;
private LocationAnimation centerAnimation;
private DoubleAnimation zoomLevelAnimation;
private DoubleAnimation headingAnimation;
static MapBase()
{ {
public static readonly DependencyProperty ForegroundProperty = BackgroundProperty.OverrideMetadata(typeof(MapBase), new FrameworkPropertyMetadata(Brushes.White));
DependencyPropertyHelper.AddOwner<MapBase, Brush>(TextElement.ForegroundProperty, Brushes.Black); ClipToBoundsProperty.OverrideMetadata(typeof(MapBase), new FrameworkPropertyMetadata(true));
DefaultStyleKeyProperty.OverrideMetadata(typeof(MapBase), new FrameworkPropertyMetadata(typeof(MapBase)));
}
public static readonly DependencyProperty AnimationEasingFunctionProperty = /// <summary>
DependencyPropertyHelper.Register<MapBase, IEasingFunction>(nameof(AnimationEasingFunction), /// Gets or sets the EasingFunction of the Center, ZoomLevel and Heading animations.
new QuadraticEase { EasingMode = EasingMode.EaseOut }); /// The default value is a QuadraticEase with EasingMode.EaseOut.
/// </summary>
public IEasingFunction AnimationEasingFunction
{
get => (IEasingFunction)GetValue(AnimationEasingFunctionProperty);
set => SetValue(AnimationEasingFunctionProperty, value);
}
public static readonly DependencyProperty CenterProperty = /// <summary>
DependencyPropertyHelper.Register<MapBase, Location>(nameof(Center), new Location(0d, 0d), /// Gets the scaling factor from projected map coordinates to view coordinates,
(map, oldValue, newValue) => map.CenterPropertyChanged(newValue), /// as pixels per meter.
(map, value) => map.CoerceCenterProperty(value), /// </summary>
true); public double ViewScale
{
get => (double)GetValue(ViewScaleProperty);
private set => SetValue(ViewScalePropertyKey, value);
}
public static readonly DependencyProperty TargetCenterProperty = protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo)
DependencyPropertyHelper.Register<MapBase, Location>(nameof(TargetCenter), new Location(0d, 0d), {
(map, oldValue, newValue) => map.TargetCenterPropertyChanged(newValue), base.OnRenderSizeChanged(sizeInfo);
(map, value) => map.CoerceCenterProperty(value),
true);
public static readonly DependencyProperty MinZoomLevelProperty = ResetTransformCenter();
DependencyPropertyHelper.Register<MapBase, double>(nameof(MinZoomLevel), 1d, UpdateTransform();
(map, oldValue, newValue) => map.MinZoomLevelPropertyChanged(newValue), }
(map, value) => map.CoerceMinZoomLevelProperty(value));
public static readonly DependencyProperty MaxZoomLevelProperty = private void CenterPropertyChanged(Location center)
DependencyPropertyHelper.Register<MapBase, double>(nameof(MaxZoomLevel), 20d, {
(map, oldValue, newValue) => map.MaxZoomLevelPropertyChanged(newValue), if (!internalPropertyChange)
(map, value) => map.CoerceMaxZoomLevelProperty(value));
public static readonly DependencyProperty ZoomLevelProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(ZoomLevel), 1d,
(map, oldValue, newValue) => map.ZoomLevelPropertyChanged(newValue),
(map, value) => map.CoerceZoomLevelProperty(value),
true);
public static readonly DependencyProperty TargetZoomLevelProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(TargetZoomLevel), 1d,
(map, oldValue, newValue) => map.TargetZoomLevelPropertyChanged(newValue),
(map, value) => map.CoerceZoomLevelProperty(value),
true);
public static readonly DependencyProperty HeadingProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(Heading), 0d,
(map, oldValue, newValue) => map.HeadingPropertyChanged(newValue),
(map, value) => map.CoerceHeadingProperty(value),
true);
public static readonly DependencyProperty TargetHeadingProperty =
DependencyPropertyHelper.Register<MapBase, double>(nameof(TargetHeading), 0d,
(map, oldValue, newValue) => map.TargetHeadingPropertyChanged(newValue),
(map, value) => map.CoerceHeadingProperty(value),
true);
private static readonly DependencyPropertyKey ViewScalePropertyKey =
DependencyPropertyHelper.RegisterReadOnly<MapBase, double>(nameof(ViewScale));
public static readonly DependencyProperty ViewScaleProperty = ViewScalePropertyKey.DependencyProperty;
private LocationAnimation centerAnimation;
private DoubleAnimation zoomLevelAnimation;
private DoubleAnimation headingAnimation;
static MapBase()
{ {
BackgroundProperty.OverrideMetadata(typeof(MapBase), new FrameworkPropertyMetadata(Brushes.White));
ClipToBoundsProperty.OverrideMetadata(typeof(MapBase), new FrameworkPropertyMetadata(true));
DefaultStyleKeyProperty.OverrideMetadata(typeof(MapBase), new FrameworkPropertyMetadata(typeof(MapBase)));
}
/// <summary>
/// Gets or sets the EasingFunction of the Center, ZoomLevel and Heading animations.
/// The default value is a QuadraticEase with EasingMode.EaseOut.
/// </summary>
public IEasingFunction AnimationEasingFunction
{
get => (IEasingFunction)GetValue(AnimationEasingFunctionProperty);
set => SetValue(AnimationEasingFunctionProperty, value);
}
/// <summary>
/// Gets the scaling factor from projected map coordinates to view coordinates,
/// as pixels per meter.
/// </summary>
public double ViewScale
{
get => (double)GetValue(ViewScaleProperty);
private set => SetValue(ViewScalePropertyKey, value);
}
protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo)
{
base.OnRenderSizeChanged(sizeInfo);
ResetTransformCenter();
UpdateTransform(); UpdateTransform();
}
private void CenterPropertyChanged(Location center) if (centerAnimation == null)
{
if (!internalPropertyChange)
{ {
UpdateTransform(); SetValueInternal(TargetCenterProperty, center);
if (centerAnimation == null)
{
SetValueInternal(TargetCenterProperty, center);
}
} }
} }
}
private void TargetCenterPropertyChanged(Location targetCenter) private void TargetCenterPropertyChanged(Location targetCenter)
{
if (!internalPropertyChange && !targetCenter.Equals(Center))
{ {
if (!internalPropertyChange && !targetCenter.Equals(Center)) ResetTransformCenter();
{
ResetTransformCenter();
if (centerAnimation != null)
{
centerAnimation.Completed -= CenterAnimationCompleted;
}
centerAnimation = new LocationAnimation
{
To = new Location(targetCenter.Latitude, NearestLongitude(targetCenter.Longitude)),
Duration = AnimationDuration,
EasingFunction = AnimationEasingFunction
};
centerAnimation.Completed += CenterAnimationCompleted;
BeginAnimation(CenterProperty, centerAnimation, HandoffBehavior.Compose);
}
}
private void CenterAnimationCompleted(object sender, object e)
{
if (centerAnimation != null) if (centerAnimation != null)
{ {
centerAnimation.Completed -= CenterAnimationCompleted; centerAnimation.Completed -= CenterAnimationCompleted;
centerAnimation = null;
SetValueInternal(CenterProperty, TargetCenter);
UpdateTransform();
BeginAnimation(CenterProperty, null);
} }
}
private void MinZoomLevelPropertyChanged(double minZoomLevel) centerAnimation = new LocationAnimation
{
if (ZoomLevel < minZoomLevel)
{ {
ZoomLevel = minZoomLevel; To = new Location(targetCenter.Latitude, NearestLongitude(targetCenter.Longitude)),
} Duration = AnimationDuration,
} EasingFunction = AnimationEasingFunction
};
private void MaxZoomLevelPropertyChanged(double maxZoomLevel) centerAnimation.Completed += CenterAnimationCompleted;
BeginAnimation(CenterProperty, centerAnimation, HandoffBehavior.Compose);
}
}
private void CenterAnimationCompleted(object sender, object e)
{
if (centerAnimation != null)
{ {
if (ZoomLevel > maxZoomLevel) centerAnimation.Completed -= CenterAnimationCompleted;
{ centerAnimation = null;
ZoomLevel = maxZoomLevel;
}
}
private void ZoomLevelPropertyChanged(double zoomLevel) SetValueInternal(CenterProperty, TargetCenter);
UpdateTransform();
BeginAnimation(CenterProperty, null);
}
}
private void MinZoomLevelPropertyChanged(double minZoomLevel)
{
if (ZoomLevel < minZoomLevel)
{ {
if (!internalPropertyChange) ZoomLevel = minZoomLevel;
{
UpdateTransform();
if (zoomLevelAnimation == null)
{
SetValueInternal(TargetZoomLevelProperty, zoomLevel);
}
}
} }
}
private void TargetZoomLevelPropertyChanged(double targetZoomLevel) private void MaxZoomLevelPropertyChanged(double maxZoomLevel)
{
if (ZoomLevel > maxZoomLevel)
{ {
if (!internalPropertyChange && targetZoomLevel != ZoomLevel) ZoomLevel = maxZoomLevel;
}
}
private void ZoomLevelPropertyChanged(double zoomLevel)
{
if (!internalPropertyChange)
{
UpdateTransform();
if (zoomLevelAnimation == null)
{ {
if (zoomLevelAnimation != null) SetValueInternal(TargetZoomLevelProperty, zoomLevel);
{
zoomLevelAnimation.Completed -= ZoomLevelAnimationCompleted;
}
zoomLevelAnimation = new DoubleAnimation
{
To = targetZoomLevel,
Duration = AnimationDuration,
EasingFunction = AnimationEasingFunction
};
zoomLevelAnimation.Completed += ZoomLevelAnimationCompleted;
BeginAnimation(ZoomLevelProperty, zoomLevelAnimation, HandoffBehavior.Compose);
} }
} }
}
private void ZoomLevelAnimationCompleted(object sender, object e) private void TargetZoomLevelPropertyChanged(double targetZoomLevel)
{
if (!internalPropertyChange && targetZoomLevel != ZoomLevel)
{ {
if (zoomLevelAnimation != null) if (zoomLevelAnimation != null)
{ {
zoomLevelAnimation.Completed -= ZoomLevelAnimationCompleted; zoomLevelAnimation.Completed -= ZoomLevelAnimationCompleted;
zoomLevelAnimation = null;
SetValueInternal(ZoomLevelProperty, TargetZoomLevel);
UpdateTransform(true);
BeginAnimation(ZoomLevelProperty, null);
} }
}
private void HeadingPropertyChanged(double heading) zoomLevelAnimation = new DoubleAnimation
{
if (!internalPropertyChange)
{ {
UpdateTransform(); To = targetZoomLevel,
Duration = AnimationDuration,
EasingFunction = AnimationEasingFunction
};
if (headingAnimation == null) zoomLevelAnimation.Completed += ZoomLevelAnimationCompleted;
{
SetValueInternal(TargetHeadingProperty, heading); BeginAnimation(ZoomLevelProperty, zoomLevelAnimation, HandoffBehavior.Compose);
}
}
} }
}
private void TargetHeadingPropertyChanged(double targetHeading) private void ZoomLevelAnimationCompleted(object sender, object e)
{
if (zoomLevelAnimation != null)
{ {
if (!internalPropertyChange && targetHeading != Heading) zoomLevelAnimation.Completed -= ZoomLevelAnimationCompleted;
{ zoomLevelAnimation = null;
var delta = targetHeading - Heading;
if (delta > 180d) SetValueInternal(ZoomLevelProperty, TargetZoomLevel);
{ UpdateTransform(true);
delta -= 360d; BeginAnimation(ZoomLevelProperty, null);
}
else if (delta < -180d)
{
delta += 360d;
}
if (headingAnimation != null)
{
headingAnimation.Completed -= HeadingAnimationCompleted;
}
headingAnimation = new DoubleAnimation
{
By = delta,
Duration = AnimationDuration,
EasingFunction = AnimationEasingFunction
};
headingAnimation.Completed += HeadingAnimationCompleted;
BeginAnimation(HeadingProperty, headingAnimation, HandoffBehavior.SnapshotAndReplace); // don't compose
}
} }
}
private void HeadingAnimationCompleted(object sender, object e) private void HeadingPropertyChanged(double heading)
{
if (!internalPropertyChange)
{ {
if (headingAnimation != null) UpdateTransform();
{
headingAnimation.Completed -= HeadingAnimationCompleted;
headingAnimation = null;
SetValueInternal(HeadingProperty, TargetHeading); if (headingAnimation == null)
UpdateTransform(); {
BeginAnimation(HeadingProperty, null); SetValueInternal(TargetHeadingProperty, heading);
} }
} }
} }
private void TargetHeadingPropertyChanged(double targetHeading)
{
if (!internalPropertyChange && targetHeading != Heading)
{
var delta = targetHeading - Heading;
if (delta > 180d)
{
delta -= 360d;
}
else if (delta < -180d)
{
delta += 360d;
}
if (headingAnimation != null)
{
headingAnimation.Completed -= HeadingAnimationCompleted;
}
headingAnimation = new DoubleAnimation
{
By = delta,
Duration = AnimationDuration,
EasingFunction = AnimationEasingFunction
};
headingAnimation.Completed += HeadingAnimationCompleted;
BeginAnimation(HeadingProperty, headingAnimation, HandoffBehavior.SnapshotAndReplace); // don't compose
}
}
private void HeadingAnimationCompleted(object sender, object e)
{
if (headingAnimation != null)
{
headingAnimation.Completed -= HeadingAnimationCompleted;
headingAnimation = null;
SetValueInternal(HeadingProperty, TargetHeading);
UpdateTransform();
BeginAnimation(HeadingProperty, null);
}
}
} }

View file

@ -1,29 +1,28 @@
using System.Windows; using System.Windows;
namespace MapControl namespace MapControl;
public partial class MapContentControl
{ {
public partial class MapContentControl static MapContentControl()
{ {
static MapContentControl() DefaultStyleKeyProperty.OverrideMetadata(typeof(MapContentControl), new FrameworkPropertyMetadata(typeof(MapContentControl)));
{ }
DefaultStyleKeyProperty.OverrideMetadata(typeof(MapContentControl), new FrameworkPropertyMetadata(typeof(MapContentControl))); }
}
} public partial class Pushpin
{
public partial class Pushpin static Pushpin()
{ {
static Pushpin() DefaultStyleKeyProperty.OverrideMetadata(typeof(Pushpin), new FrameworkPropertyMetadata(typeof(Pushpin)));
{ }
DefaultStyleKeyProperty.OverrideMetadata(typeof(Pushpin), new FrameworkPropertyMetadata(typeof(Pushpin)));
} public static readonly DependencyProperty CornerRadiusProperty =
DependencyPropertyHelper.Register<Pushpin, CornerRadius>(nameof(CornerRadius));
public static readonly DependencyProperty CornerRadiusProperty =
DependencyPropertyHelper.Register<Pushpin, CornerRadius>(nameof(CornerRadius)); public CornerRadius CornerRadius
{
public CornerRadius CornerRadius get => (CornerRadius)GetValue(CornerRadiusProperty);
{ set => SetValue(CornerRadiusProperty, value);
get => (CornerRadius)GetValue(CornerRadiusProperty);
set => SetValue(CornerRadiusProperty, value);
}
} }
} }

View file

@ -4,109 +4,108 @@ using System.Windows;
using System.Windows.Documents; using System.Windows.Documents;
using System.Windows.Media; using System.Windows.Media;
namespace MapControl namespace MapControl;
public partial class MapGrid : FrameworkElement, IMapElement
{ {
public partial class MapGrid : FrameworkElement, IMapElement public static readonly DependencyProperty ForegroundProperty =
DependencyPropertyHelper.AddOwner<MapGrid, Brush>(TextElement.ForegroundProperty);
public static readonly DependencyProperty FontFamilyProperty =
DependencyPropertyHelper.AddOwner<MapGrid, FontFamily>(TextElement.FontFamilyProperty);
public static readonly DependencyProperty FontSizeProperty =
DependencyPropertyHelper.AddOwner<MapGrid, double>(TextElement.FontSizeProperty, 12d);
/// <summary>
/// Implements IMapElement.ParentMap.
/// </summary>
public MapBase ParentMap
{ {
public static readonly DependencyProperty ForegroundProperty = get;
DependencyPropertyHelper.AddOwner<MapGrid, Brush>(TextElement.ForegroundProperty); set
public static readonly DependencyProperty FontFamilyProperty =
DependencyPropertyHelper.AddOwner<MapGrid, FontFamily>(TextElement.FontFamilyProperty);
public static readonly DependencyProperty FontSizeProperty =
DependencyPropertyHelper.AddOwner<MapGrid, double>(TextElement.FontSizeProperty, 12d);
/// <summary>
/// Implements IMapElement.ParentMap.
/// </summary>
public MapBase ParentMap
{ {
get; if (field != null)
set
{ {
if (field != null) field.ViewportChanged -= OnViewportChanged;
{ }
field.ViewportChanged -= OnViewportChanged;
}
field = value; field = value;
if (field != null) if (field != null)
{ {
field.ViewportChanged += OnViewportChanged; field.ViewportChanged += OnViewportChanged;
}
} }
} }
}
private void OnViewportChanged(object sender, ViewportChangedEventArgs e) private void OnViewportChanged(object sender, ViewportChangedEventArgs e)
{ {
OnViewportChanged(e); OnViewportChanged(e);
} }
protected virtual void OnViewportChanged(ViewportChangedEventArgs e) protected virtual void OnViewportChanged(ViewportChangedEventArgs e)
{ {
InvalidateVisual(); InvalidateVisual();
} }
protected override void OnRender(DrawingContext drawingContext) protected override void OnRender(DrawingContext drawingContext)
{
if (ParentMap != null)
{ {
if (ParentMap != null) var pathGeometry = new PathGeometry();
var labels = new List<Label>();
var pen = new Pen
{ {
var pathGeometry = new PathGeometry(); Brush = Foreground,
var labels = new List<Label>(); Thickness = StrokeThickness,
var pen = new Pen };
DrawGrid(pathGeometry.Figures, labels);
drawingContext.DrawGeometry(null, pen, pathGeometry);
if (labels.Count > 0)
{
var typeface = new Typeface(FontFamily, FontStyles.Normal, FontWeights.Normal, FontStretches.Normal);
var pixelsPerDip = VisualTreeHelper.GetDpi(this).PixelsPerDip;
foreach (var label in labels)
{ {
Brush = Foreground, var text = new FormattedText(label.Text,
Thickness = StrokeThickness, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, FontSize, Foreground, pixelsPerDip);
}; var x = label.X +
label.HorizontalAlignment switch
{
HorizontalAlignment.Left => 2d,
HorizontalAlignment.Right => -text.Width - 2d,
_ => -text.Width / 2d
};
var y = label.Y +
label.VerticalAlignment switch
{
VerticalAlignment.Top => 0,
VerticalAlignment.Bottom => -text.Height,
_ => -text.Height / 2d
};
DrawGrid(pathGeometry.Figures, labels); if (label.Rotation != 0d)
drawingContext.DrawGeometry(null, pen, pathGeometry);
if (labels.Count > 0)
{
var typeface = new Typeface(FontFamily, FontStyles.Normal, FontWeights.Normal, FontStretches.Normal);
var pixelsPerDip = VisualTreeHelper.GetDpi(this).PixelsPerDip;
foreach (var label in labels)
{ {
var text = new FormattedText(label.Text, drawingContext.PushTransform(new RotateTransform(label.Rotation, label.X, label.Y));
CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, FontSize, Foreground, pixelsPerDip); drawingContext.DrawText(text, new Point(x, y));
var x = label.X + drawingContext.Pop();
label.HorizontalAlignment switch }
{ else
HorizontalAlignment.Left => 2d, {
HorizontalAlignment.Right => -text.Width - 2d, drawingContext.DrawText(text, new Point(x, y));
_ => -text.Width / 2d
};
var y = label.Y +
label.VerticalAlignment switch
{
VerticalAlignment.Top => 0,
VerticalAlignment.Bottom => -text.Height,
_ => -text.Height / 2d
};
if (label.Rotation != 0d)
{
drawingContext.PushTransform(new RotateTransform(label.Rotation, label.X, label.Y));
drawingContext.DrawText(text, new Point(x, y));
drawingContext.Pop();
}
else
{
drawingContext.DrawText(text, new Point(x, y));
}
} }
} }
} }
} }
}
private static PolyLineSegment CreatePolyLineSegment(IEnumerable<Point> points) private static PolyLineSegment CreatePolyLineSegment(IEnumerable<Point> points)
{ {
return new PolyLineSegment(points, true); return new PolyLineSegment(points, true);
}
} }
} }

View file

@ -2,35 +2,34 @@
using System.Windows; using System.Windows;
using System.Windows.Media.Animation; using System.Windows.Media.Animation;
namespace MapControl namespace MapControl;
public partial class MapImageLayer
{ {
public partial class MapImageLayer private void FadeOver()
{ {
private void FadeOver() var fadeInAnimation = new DoubleAnimation
{ {
var fadeInAnimation = new DoubleAnimation To = 1d,
{ Duration = MapBase.ImageFadeDuration
To = 1d, };
Duration = MapBase.ImageFadeDuration
};
var fadeOutAnimation = new DoubleAnimation var fadeOutAnimation = new DoubleAnimation
{ {
To = 0d, To = 0d,
BeginTime = MapBase.ImageFadeDuration, BeginTime = MapBase.ImageFadeDuration,
Duration = TimeSpan.Zero Duration = TimeSpan.Zero
}; };
Storyboard.SetTarget(fadeInAnimation, Children[1]); Storyboard.SetTarget(fadeInAnimation, Children[1]);
Storyboard.SetTargetProperty(fadeInAnimation, new PropertyPath(OpacityProperty)); Storyboard.SetTargetProperty(fadeInAnimation, new PropertyPath(OpacityProperty));
Storyboard.SetTarget(fadeOutAnimation, Children[0]); Storyboard.SetTarget(fadeOutAnimation, Children[0]);
Storyboard.SetTargetProperty(fadeOutAnimation, new PropertyPath(OpacityProperty)); Storyboard.SetTargetProperty(fadeOutAnimation, new PropertyPath(OpacityProperty));
var storyboard = new Storyboard(); var storyboard = new Storyboard();
storyboard.Children.Add(fadeInAnimation); storyboard.Children.Add(fadeInAnimation);
storyboard.Children.Add(fadeOutAnimation); storyboard.Children.Add(fadeOutAnimation);
storyboard.Begin(); storyboard.Begin();
}
} }
} }

View file

@ -2,38 +2,37 @@
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Input; using System.Windows.Input;
namespace MapControl namespace MapControl;
public partial class MapItem
{ {
public partial class MapItem static MapItem()
{ {
static MapItem() DefaultStyleKeyProperty.OverrideMetadata(typeof(MapItem), new FrameworkPropertyMetadata(typeof(MapItem)));
{ }
DefaultStyleKeyProperty.OverrideMetadata(typeof(MapItem), new FrameworkPropertyMetadata(typeof(MapItem)));
}
protected override void OnTouchDown(TouchEventArgs e) protected override void OnTouchDown(TouchEventArgs e)
{
e.Handled = true;
}
protected override void OnTouchUp(TouchEventArgs e)
{
e.Handled = true;
}
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift) &&
ItemsControl.ItemsControlFromItemContainer(this) is MapItemsControl mapItemsControl &&
mapItemsControl.SelectionMode == SelectionMode.Extended)
{ {
mapItemsControl.SelectItemsInRange(this);
e.Handled = true; e.Handled = true;
} }
else
protected override void OnTouchUp(TouchEventArgs e)
{ {
e.Handled = true; base.OnMouseLeftButtonDown(e);
}
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift) &&
ItemsControl.ItemsControlFromItemContainer(this) is MapItemsControl mapItemsControl &&
mapItemsControl.SelectionMode == SelectionMode.Extended)
{
mapItemsControl.SelectItemsInRange(this);
e.Handled = true;
}
else
{
base.OnMouseLeftButtonDown(e);
}
} }
} }
} }

View file

@ -2,50 +2,49 @@
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Media; using System.Windows.Media;
namespace MapControl namespace MapControl;
public partial class MapItemsControl
{ {
public partial class MapItemsControl static MapItemsControl()
{ {
static MapItemsControl() DefaultStyleKeyProperty.OverrideMetadata(typeof(MapItemsControl), new FrameworkPropertyMetadata(typeof(MapItemsControl)));
{ }
DefaultStyleKeyProperty.OverrideMetadata(typeof(MapItemsControl), new FrameworkPropertyMetadata(typeof(MapItemsControl)));
}
public void SelectItemsInGeometry(Geometry geometry) public void SelectItemsInGeometry(Geometry geometry)
{ {
SelectItemsByPosition(geometry.FillContains); SelectItemsByPosition(geometry.FillContains);
} }
public MapItem ContainerFromItem(object item) public MapItem ContainerFromItem(object item)
{ {
return (MapItem)ItemContainerGenerator.ContainerFromItem(item); return (MapItem)ItemContainerGenerator.ContainerFromItem(item);
} }
public object ItemFromContainer(MapItem container) public object ItemFromContainer(MapItem container)
{ {
return ItemContainerGenerator.ItemFromContainer(container); return ItemContainerGenerator.ItemFromContainer(container);
} }
protected override bool IsItemItsOwnContainerOverride(object item) protected override bool IsItemItsOwnContainerOverride(object item)
{ {
return item is MapItem; return item is MapItem;
} }
protected override DependencyObject GetContainerForItemOverride() protected override DependencyObject GetContainerForItemOverride()
{ {
return new MapItem(); return new MapItem();
} }
protected override void PrepareContainerForItemOverride(DependencyObject container, object item) protected override void PrepareContainerForItemOverride(DependencyObject container, object item)
{ {
base.PrepareContainerForItemOverride(container, item); base.PrepareContainerForItemOverride(container, item);
PrepareContainer((MapItem)container, item); PrepareContainer((MapItem)container, item);
} }
protected override void ClearContainerForItemOverride(DependencyObject container, object item) protected override void ClearContainerForItemOverride(DependencyObject container, object item)
{ {
base.ClearContainerForItemOverride(container, item); base.ClearContainerForItemOverride(container, item);
ClearContainer((MapItem)container); ClearContainer((MapItem)container);
}
} }
} }

View file

@ -1,27 +1,26 @@
using System.Windows; using System.Windows;
namespace MapControl namespace MapControl;
public partial class MapPanel
{ {
public partial class MapPanel public static readonly DependencyProperty AutoCollapseProperty =
DependencyPropertyHelper.RegisterAttached< bool>("AutoCollapse", typeof(MapPanel));
public static readonly DependencyProperty LocationProperty =
DependencyPropertyHelper.RegisterAttached<Location>("Location", typeof(MapPanel), null,
FrameworkPropertyMetadataOptions.AffectsParentArrange);
public static readonly DependencyProperty BoundingBoxProperty =
DependencyPropertyHelper.RegisterAttached<BoundingBox>("BoundingBox", typeof(MapPanel), null,
FrameworkPropertyMetadataOptions.AffectsParentArrange);
public static readonly DependencyProperty MapRectProperty =
DependencyPropertyHelper.RegisterAttached<Rect?>("MapRect", typeof(MapPanel), null,
FrameworkPropertyMetadataOptions.AffectsParentArrange);
public static MapBase GetParentMap(FrameworkElement element)
{ {
public static readonly DependencyProperty AutoCollapseProperty = return (MapBase)element.GetValue(ParentMapProperty);
DependencyPropertyHelper.RegisterAttached< bool>("AutoCollapse", typeof(MapPanel));
public static readonly DependencyProperty LocationProperty =
DependencyPropertyHelper.RegisterAttached<Location>("Location", typeof(MapPanel), null,
FrameworkPropertyMetadataOptions.AffectsParentArrange);
public static readonly DependencyProperty BoundingBoxProperty =
DependencyPropertyHelper.RegisterAttached<BoundingBox>("BoundingBox", typeof(MapPanel), null,
FrameworkPropertyMetadataOptions.AffectsParentArrange);
public static readonly DependencyProperty MapRectProperty =
DependencyPropertyHelper.RegisterAttached<Rect?>("MapRect", typeof(MapPanel), null,
FrameworkPropertyMetadataOptions.AffectsParentArrange);
public static MapBase GetParentMap(FrameworkElement element)
{
return (MapBase)element.GetValue(ParentMapProperty);
}
} }
} }

View file

@ -2,36 +2,35 @@
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Shapes; using System.Windows.Shapes;
namespace MapControl namespace MapControl;
public partial class MapPath : Shape
{ {
public partial class MapPath : Shape public static readonly DependencyProperty DataProperty =
DependencyPropertyHelper.AddOwner<MapPath, Geometry>(Path.DataProperty,
(path, oldValue, newValue) => path.DataPropertyChanged(oldValue, newValue));
public Geometry Data
{ {
public static readonly DependencyProperty DataProperty = get => (Geometry)GetValue(DataProperty);
DependencyPropertyHelper.AddOwner<MapPath, Geometry>(Path.DataProperty, set => SetValue(DataProperty, value);
(path, oldValue, newValue) => path.DataPropertyChanged(oldValue, newValue)); }
public Geometry Data protected override Geometry DefiningGeometry => Data;
private void DataPropertyChanged(Geometry oldValue, Geometry newValue)
{
// Check if Data is actually a new Geometry.
//
if (newValue != null && !ReferenceEquals(newValue, oldValue))
{ {
get => (Geometry)GetValue(DataProperty); if (newValue.IsFrozen)
set => SetValue(DataProperty, value);
}
protected override Geometry DefiningGeometry => Data;
private void DataPropertyChanged(Geometry oldValue, Geometry newValue)
{
// Check if Data is actually a new Geometry.
//
if (newValue != null && !ReferenceEquals(newValue, oldValue))
{ {
if (newValue.IsFrozen) Data = newValue.Clone(); // DataPropertyChanged called again
{ }
Data = newValue.Clone(); // DataPropertyChanged called again else
} {
else UpdateData();
{
UpdateData();
}
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show more