Fix native map services and add provider selection

This commit is contained in:
livio committed 2026-09-11 19:25:09 +02:00
1 parent 56211e4b25
commit 3ea51a6842
9 files changed
+1408 -129

No files matched your search

+185
View File
@@ -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);
+85
View File
@@ -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);
}
}
}