From 64d7d3818acf3fa74449c14e5da3340c6e167173 Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Sat, 12 Sep 2026 10:28:48 +0200 Subject: [PATCH 01/11] Location search feature --- .../Helpers/NominatimLocationSearcher.cs | 76 ++++++++++++++++ PhotoLocator/MainViewModel.cs | 86 +++++++++++++++++++ PhotoLocator/MainWindow.xaml | 24 +++++- PhotoLocator/MainWindow.xaml.cs | 32 +++++++ 4 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 PhotoLocator/Helpers/NominatimLocationSearcher.cs diff --git a/PhotoLocator/Helpers/NominatimLocationSearcher.cs b/PhotoLocator/Helpers/NominatimLocationSearcher.cs new file mode 100644 index 0000000..a01bd6c --- /dev/null +++ b/PhotoLocator/Helpers/NominatimLocationSearcher.cs @@ -0,0 +1,76 @@ +using MapControl; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace PhotoLocator.Helpers +{ + public sealed class NominatimLocationSearcher + { + static readonly Uri _searchEndpoint = new("https://nominatim.openstreetmap.org/search"); + static HttpClient? _sharedHttpClient; + + static HttpClient CreateHttpClient() + { + var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("PhotoLocator"); + return httpClient; + } + + readonly HttpClient _httpClient; + + public NominatimLocationSearcher(HttpClient? httpClient = null) + { + _httpClient = httpClient ?? (_sharedHttpClient ??= CreateHttpClient()); + } + + public async Task> SearchAsync(string searchText, int resultLimit = 10, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(searchText); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resultLimit); + + var requestUri = new UriBuilder(_searchEndpoint) + { + Query = $"q={Uri.EscapeDataString(searchText)}&format=jsonv2&limit={resultLimit}" + }.Uri; + + var results = await _httpClient.GetFromJsonAsync(requestUri, cancellationToken) + .ConfigureAwait(false); + + if (results is null) + return []; + + return results + .Where(result => + result.DisplayName is not null && + double.TryParse(result.Latitude, NumberStyles.Float, CultureInfo.InvariantCulture, out _) && + double.TryParse(result.Longitude, NumberStyles.Float, CultureInfo.InvariantCulture, out _)) + .Select(result => new LocationWithName( + result.DisplayName!, + new Location( + double.Parse(result.Latitude!, CultureInfo.InvariantCulture), + double.Parse(result.Longitude!, CultureInfo.InvariantCulture)))) + .ToArray(); + } + + sealed class NominatimResult + { + [JsonPropertyName("display_name")] + public string? DisplayName { get; set; } + + [JsonPropertyName("lat")] + public string? Latitude { get; set; } + + [JsonPropertyName("lon")] + public string? Longitude { get; set; } + } + } + + public sealed record LocationWithName(string DisplayName, Location Location); +} diff --git a/PhotoLocator/MainViewModel.cs b/PhotoLocator/MainViewModel.cs index fa7a6f6..83f9628 100644 --- a/PhotoLocator/MainViewModel.cs +++ b/PhotoLocator/MainViewModel.cs @@ -46,6 +46,7 @@ public sealed class MainViewModel : INotifyPropertyChanged, IDisposable, IMainVi CancellationTokenSource? _loadCancellation; CancellationTokenSource? _previewCancellation; CancellationTokenSource? _processCancellation; + CancellationTokenSource? _locationSearchCancellation; FileSystemWatcher? _fileSystemWatcher; double _loadImagesProgress; bool _titleUpdatePending; @@ -53,6 +54,8 @@ public sealed class MainViewModel : INotifyPropertyChanged, IDisposable, IMainVi readonly List<(string Path, BitmapSource Picture)> _pictureCache = []; readonly HashSet _gpsTraceFiles = []; Location? _sunAndMoonMapCenter; + NominatimLocationSearcher? _locationSearcher; + LocationWithName? _previewedLocationSearchResult; public event PropertyChangedEventHandler? PropertyChanged; @@ -149,6 +152,86 @@ public Location? MapCenter set => SetProperty(ref field, value); } + public bool IsLocationSearchVisible + { + get; + set + { + if (SetProperty(ref field, value) && !value) + { + _locationSearchCancellation?.Cancel(); + _previewedLocationSearchResult = null; + SelectedLocationSearchResult = null; + } + } + } + + public string LocationSearchText + { + get; + set + { + if (SetProperty(ref field, value)) + SearchLocationsAsync(value).WithExceptionLogging(); + } + } = string.Empty; + + public ObservableCollection LocationSearchResults { get; } = []; + + public LocationWithName? SelectedLocationSearchResult + { + get; + set + { + if (!SetProperty(ref field, value) || value is null) + return; + MapCenter = value.Location; + IsLocationSearchVisible = false; + } + } + + public void PreviewLocationSearchResult(LocationWithName result) + { + if (ReferenceEquals(_previewedLocationSearchResult, result)) + return; + _previewedLocationSearchResult = result; + MapCenter = result.Location; + } + + async Task SearchLocationsAsync(string searchText) + { + await (_locationSearchCancellation?.CancelAsync() ?? Task.CompletedTask); + _locationSearchCancellation?.Dispose(); + _locationSearchCancellation = null; + if (string.IsNullOrWhiteSpace(searchText)) + return; + + using var cursor = new MouseCursorOverride(Cursors.AppStarting); + var cancellation = new CancellationTokenSource(); + _locationSearchCancellation = cancellation; + try + { + await Task.Delay(500, cancellation.Token); + _locationSearcher ??= new NominatimLocationSearcher(); + var results = await _locationSearcher.SearchAsync(searchText, 10, cancellation.Token); + if (!ReferenceEquals(_locationSearchCancellation, cancellation)) + return; + LocationSearchResults.Clear(); + foreach (var result in results) + LocationSearchResults.Add(result); + } + finally + { + if (ReferenceEquals(_locationSearchCancellation, cancellation)) + { + _locationSearchCancellation = null; + cancellation.Dispose(); + } + } + } + + public ICommand LocationSearchCommand => new RelayCommand(o => IsLocationSearchVisible = !IsLocationSearchVisible); + public Location? SavedLocation { get; @@ -1400,6 +1483,9 @@ public void Dispose() _processCancellation?.Cancel(); _processCancellation?.Dispose(); _processCancellation = null; + _locationSearchCancellation?.Cancel(); + _locationSearchCancellation?.Dispose(); + _locationSearchCancellation = null; DisposeFileSystemWatcher(); } } diff --git a/PhotoLocator/MainWindow.xaml b/PhotoLocator/MainWindow.xaml index 38513e6..009d73e 100644 --- a/PhotoLocator/MainWindow.xaml +++ b/PhotoLocator/MainWindow.xaml @@ -33,6 +33,7 @@ + @@ -121,6 +122,25 @@