Fix native map services and add provider selection
This commit is contained in:
1 parent
56211e4b25
commit
3ea51a6842
9 files changed
+1408
-129
No files matched your search
@@ -0,0 +1,185 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Maui.Devices.Sensors;
|
||||
|
||||
namespace LMEMappeCatastaliApp.Services;
|
||||
|
||||
public sealed class LocationSearchService
|
||||
{
|
||||
private const string NominatimEndpoint = "https://nominatim.openstreetmap.org/search";
|
||||
private const string ArcGisEndpoint = "https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates";
|
||||
private readonly HttpClient _httpClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(20)
|
||||
};
|
||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
||||
private readonly Dictionary<string, LocationSearchResult?> _cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DateTimeOffset _lastRemoteRequest = DateTimeOffset.MinValue;
|
||||
|
||||
public LocationSearchService()
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.2");
|
||||
_httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
_httpClient.DefaultRequestHeaders.AcceptLanguage.ParseAdd("it-IT");
|
||||
}
|
||||
|
||||
public async Task<LocationSearchResult?> SearchAsync(string text, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = text.Trim();
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (TryParseCoordinates(query, out var coordinates))
|
||||
{
|
||||
return coordinates;
|
||||
}
|
||||
|
||||
if (_cache.TryGetValue(query, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var nativeResult = await TryNativeGeocodingAsync(query);
|
||||
if (nativeResult is not null)
|
||||
{
|
||||
_cache[query] = nativeResult;
|
||||
return nativeResult;
|
||||
}
|
||||
|
||||
var arcGisResult = await SearchArcGisAsync(query, cancellationToken);
|
||||
if (arcGisResult is not null)
|
||||
{
|
||||
return arcGisResult;
|
||||
}
|
||||
|
||||
var remoteResult = await SearchNominatimAsync(query, cancellationToken);
|
||||
_cache[query] = remoteResult;
|
||||
return remoteResult;
|
||||
}
|
||||
|
||||
private async Task<LocationSearchResult?> SearchArcGisAsync(string query, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"{ArcGisEndpoint}?f=json&forStorage=false&sourceCountry=ITA&maxLocations=1&outFields=Match_addr,Addr_type,Score&SingleLine={Uri.EscapeDataString(query)}";
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var content = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var payload = await JsonSerializer.DeserializeAsync<ArcGisResponse>(content, cancellationToken: cancellationToken);
|
||||
var candidate = payload?.Candidates?.FirstOrDefault();
|
||||
if (candidate?.Location is null || candidate.Score < 60)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LocationSearchResult(candidate.Location.Y, candidate.Location.X, candidate.Address ?? query);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<LocationSearchResult?> TryNativeGeocodingAsync(string query)
|
||||
{
|
||||
try
|
||||
{
|
||||
var locations = await Geocoding.Default.GetLocationsAsync(query);
|
||||
var location = locations?.FirstOrDefault();
|
||||
return location is null
|
||||
? null
|
||||
: new LocationSearchResult(location.Latitude, location.Longitude, query);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LocationSearchResult?> SearchNominatimAsync(string query, CancellationToken cancellationToken)
|
||||
{
|
||||
await _requestLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var wait = TimeSpan.FromSeconds(1) - (DateTimeOffset.UtcNow - _lastRemoteRequest);
|
||||
if (wait > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(wait, cancellationToken);
|
||||
}
|
||||
|
||||
var url = $"{NominatimEndpoint}?format=jsonv2&limit=1&accept-language=it&q={Uri.EscapeDataString(query)}";
|
||||
_lastRemoteRequest = DateTimeOffset.UtcNow;
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Il servizio di ricerca ha risposto {((int)response.StatusCode)}.");
|
||||
}
|
||||
|
||||
await using var content = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var results = await JsonSerializer.DeserializeAsync<List<NominatimResult>>(content, cancellationToken: cancellationToken);
|
||||
var result = results?.FirstOrDefault();
|
||||
if (result is null
|
||||
|| !double.TryParse(result.Latitude, NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude)
|
||||
|| !double.TryParse(result.Longitude, NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LocationSearchResult(latitude, longitude, result.DisplayName ?? query);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_requestLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseCoordinates(string query, out LocationSearchResult? result)
|
||||
{
|
||||
result = null;
|
||||
var parts = query.Split([' ', ',', ';'], StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 2
|
||||
|| !double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude)
|
||||
|| !double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude)
|
||||
|| Math.Abs(latitude) > 90
|
||||
|| Math.Abs(longitude) > 180)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = new LocationSearchResult(
|
||||
latitude,
|
||||
longitude,
|
||||
$"Coordinate: {latitude:F6}, {longitude:F6}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed record NominatimResult(
|
||||
[property: JsonPropertyName("lat")] string? Latitude,
|
||||
[property: JsonPropertyName("lon")] string? Longitude,
|
||||
[property: JsonPropertyName("display_name")] string? DisplayName);
|
||||
|
||||
private sealed record ArcGisResponse(
|
||||
[property: JsonPropertyName("candidates")] List<ArcGisCandidate>? Candidates);
|
||||
|
||||
private sealed record ArcGisCandidate(
|
||||
[property: JsonPropertyName("address")] string? Address,
|
||||
[property: JsonPropertyName("score")] double Score,
|
||||
[property: JsonPropertyName("location")] ArcGisLocation? Location);
|
||||
|
||||
private sealed record ArcGisLocation(
|
||||
[property: JsonPropertyName("x")] double X,
|
||||
[property: JsonPropertyName("y")] double Y);
|
||||
}
|
||||
|
||||
public sealed record LocationSearchResult(double Latitude, double Longitude, string Label);
|
||||
Reference in new issue
Block a user