186 lines
7.0 KiB
C#
186 lines
7.0 KiB
C#
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);
|