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);
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Globalization;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace LMEMappeCatastaliApp.Services;
|
||||
|
||||
public sealed class OfficialCadastreService
|
||||
{
|
||||
private const string WfsEndpoint = "https://wfs.cartografia.agenziaentrate.gov.it/inspire/wfs/owfs01.php";
|
||||
private readonly HttpClient _httpClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
public OfficialCadastreService()
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2");
|
||||
_httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/gml+xml");
|
||||
}
|
||||
|
||||
public async Task<string> GetParcelsNearAsync(
|
||||
double latitude,
|
||||
double longitude,
|
||||
double radiusMeters,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var meters = Math.Clamp(radiusMeters, 2, 30);
|
||||
var deltaLatitude = meters / 111_320d;
|
||||
var longitudeScale = Math.Cos(latitude * Math.PI / 180d);
|
||||
var deltaLongitude = meters / (111_320d * Math.Max(0.1, longitudeScale));
|
||||
var bbox = string.Join(",",
|
||||
[
|
||||
Format(latitude - deltaLatitude),
|
||||
Format(longitude - deltaLongitude),
|
||||
Format(latitude + deltaLatitude),
|
||||
Format(longitude + deltaLongitude)
|
||||
]);
|
||||
|
||||
var query = new Dictionary<string, string>
|
||||
{
|
||||
["language"] = "ita",
|
||||
["SERVICE"] = "WFS",
|
||||
["VERSION"] = "2.0.0",
|
||||
["TYPENAMES"] = "CP:CadastralParcel",
|
||||
["SRSNAME"] = "urn:ogc:def:crs:EPSG::6706",
|
||||
["BBOX"] = bbox,
|
||||
["REQUEST"] = "GetFeature",
|
||||
["COUNT"] = "20"
|
||||
};
|
||||
|
||||
var url = WfsEndpoint + "?" + string.Join("&", query.Select(item =>
|
||||
$"{Uri.EscapeDataString(item.Key)}={Uri.EscapeDataString(item.Value)}"));
|
||||
|
||||
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseContentRead, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Il servizio WFS AdE ha risposto {((int)response.StatusCode)}.");
|
||||
}
|
||||
|
||||
var xml = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
ThrowIfExceptionReport(xml);
|
||||
return xml;
|
||||
}
|
||||
|
||||
private static string Format(double value) => value.ToString("R", CultureInfo.InvariantCulture);
|
||||
|
||||
private static void ThrowIfExceptionReport(string xml)
|
||||
{
|
||||
try
|
||||
{
|
||||
var document = XDocument.Parse(xml);
|
||||
var exceptionText = document.Descendants()
|
||||
.FirstOrDefault(element => element.Name.LocalName == "ExceptionText")
|
||||
?.Value
|
||||
.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(exceptionText))
|
||||
{
|
||||
throw new InvalidOperationException($"Servizio WFS AdE: {exceptionText}");
|
||||
}
|
||||
}
|
||||
catch (System.Xml.XmlException error)
|
||||
{
|
||||
throw new InvalidDataException("Il servizio WFS AdE ha restituito una risposta non valida.", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user