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
+1398 -119

No files matched your search

+179 -13
View File
@@ -2,6 +2,8 @@
@inject IJSRuntime JS @inject IJSRuntime JS
@inject PreciseLocationService LocationService @inject PreciseLocationService LocationService
@inject CadastralCatalogService CatalogService @inject CadastralCatalogService CatalogService
@inject OfficialCadastreService OfficialCadastreService
@inject LocationSearchService LocationSearchService
@implements IAsyncDisposable @implements IAsyncDisposable
<PageTitle>LME Mappe Catastali</PageTitle> <PageTitle>LME Mappe Catastali</PageTitle>
@@ -128,8 +130,10 @@
<input @bind="ProjectName" @bind:event="oninput" placeholder="Nome progetto o POI" /> <input @bind="ProjectName" @bind:event="oninput" placeholder="Nome progetto o POI" />
</label> </label>
<button class="primary-command" @onclick="AddProjectMarker">Aggiungi POI al centro mappa</button> <button class="primary-command" @onclick="AddProjectMarker">Aggiungi POI al centro mappa</button>
<button class="secondary-command" aria-pressed="@DrawToolsVisible" @onclick="ToggleDrawTools">
@(DrawToolsVisible ? "Nascondi strumenti di disegno" : "Mostra strumenti di disegno")
</button>
<div class="button-row"> <div class="button-row">
<button @onclick="EnableDrawTools">Disegna</button>
<button @onclick="SaveWorkspace">Salva</button> <button @onclick="SaveWorkspace">Salva</button>
<button @onclick="LoadWorkspace">Carica</button> <button @onclick="LoadWorkspace">Carica</button>
</div> </div>
@@ -142,11 +146,22 @@
<h2>Visualizzazione</h2> <h2>Visualizzazione</h2>
<label> <label>
Sfondo Sfondo
<select @onchange="ChangeBaseLayer"> <select value="@SelectedBaseLayer" @onchange="ChangeBaseLayer">
<option value="satellite">Satellite</option> <option value="satellite">Satellite - Esri World Imagery</option>
<option value="osm">Strade</option> <option value="osm">Strade - OpenStreetMap</option>
<option value="streets-esri">Strade dettagliate - Esri World Street Map</option>
<option value="topographic">Topografica - Esri World Topographic</option>
<option value="terrain">Terreno - OpenTopoMap</option>
<option value="carto">Strade chiare - CARTO Voyager</option>
<option value="google-satellite">Satellite - Google Maps</option>
<option value="google-roadmap">Strade - Google Maps</option>
</select> </select>
</label> </label>
<label>
Chiave Google Maps Platform
<input type="password" @bind="GoogleMapsApiKey" placeholder="API key" autocomplete="off" />
</label>
<button class="secondary-command" @onclick="SaveGoogleMapsApiKey">Salva chiave Google</button>
<label> <label>
Trasparenza catasto Trasparenza catasto
<input type="range" min="0" max="1" step="0.05" value="0.75" @onchange="ChangeCadastreOpacity" /> <input type="range" min="0" max="1" step="0.05" value="0.75" @onchange="ChangeCadastreOpacity" />
@@ -163,7 +178,7 @@
<h2>GPS e percorso</h2> <h2>GPS e percorso</h2>
<label class="toggle-line"> <label class="toggle-line">
<input type="checkbox" checked @onchange="ToggleTrail" /> <input type="checkbox" checked="@TrailEnabled" @onchange="ToggleTrail" />
Registra la traccia mentre cammini Registra la traccia mentre cammini
</label> </label>
@@ -198,6 +213,8 @@
private string SearchText { get; set; } = string.Empty; private string SearchText { get; set; } = string.Empty;
private string ProjectName { get; set; } = string.Empty; private string ProjectName { get; set; } = string.Empty;
private string Status { get; set; } = "online"; private string Status { get; set; } = "online";
private string SelectedBaseLayer { get; set; } = "satellite";
private string GoogleMapsApiKey { get; set; } = string.Empty;
private string SelectedRegionFile { get; set; } = string.Empty; private string SelectedRegionFile { get; set; } = string.Empty;
private string SelectedMunicipalityCode { get; set; } = string.Empty; private string SelectedMunicipalityCode { get; set; } = string.Empty;
private string SelectedSheet { get; set; } = string.Empty; private string SelectedSheet { get; set; } = string.Empty;
@@ -206,7 +223,12 @@
private double? CatalogFraction { get; set; } private double? CatalogFraction { get; set; }
private bool CatalogBusy { get; set; } private bool CatalogBusy { get; set; }
private bool GpsActive { get; set; } private bool GpsActive { get; set; }
private bool DrawToolsVisible { get; set; }
private bool TrailEnabled { get; set; }
private CancellationTokenSource? CatalogCancellation { get; set; } private CancellationTokenSource? CatalogCancellation { get; set; }
private CancellationTokenSource? GpsLookupCancellation { get; set; }
private long LastGpsLookupAt;
private int GpsLookupInProgress;
private IReadOnlyList<RegionOption> Regions { get; set; } = []; private IReadOnlyList<RegionOption> Regions { get; set; } = [];
private IReadOnlyList<MunicipalityOption> Municipalities { get; set; } = []; private IReadOnlyList<MunicipalityOption> Municipalities { get; set; } = [];
@@ -247,6 +269,15 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
try
{
GoogleMapsApiKey = await SecureStorage.Default.GetAsync("google_maps_api_key") ?? string.Empty;
}
catch
{
GoogleMapsApiKey = string.Empty;
}
try try
{ {
Regions = await CatalogService.GetRegionsAsync(); Regions = await CatalogService.GetRegionsAsync();
@@ -274,10 +305,26 @@
private async Task SearchLocation() private async Task SearchLocation()
{ {
if (string.IsNullOrWhiteSpace(SearchText))
{
await JS.InvokeVoidAsync("lmeMap.showInfo", "Inserisci una localita, un indirizzo o due coordinate.");
return;
}
try try
{ {
await JS.InvokeVoidAsync("lmeMap.search", SearchText); Status = "ricerca...";
var result = await LocationSearchService.SearchAsync(SearchText);
if (result is null)
{
Status = "online";
await JS.InvokeVoidAsync("lmeMap.showInfo", "Localita non trovata. Prova ad aggiungere Comune e Provincia.");
return;
}
await JS.InvokeVoidAsync("lmeMap.showSearchResult", result.Latitude, result.Longitude, result.Label);
ActiveTab = "map"; ActiveTab = "map";
Status = "online";
} }
catch (Exception error) catch (Exception error)
{ {
@@ -322,7 +369,11 @@
{ {
Sheets = await CatalogService.GetSheetsAsync(municipality, CreateProgress(), token); Sheets = await CatalogService.GetSheetsAsync(municipality, CreateProgress(), token);
CatalogMessage = $"Seleziona uno dei {Sheets.Count} fogli disponibili."; CatalogMessage = $"Seleziona uno dei {Sheets.Count} fogli disponibili.";
await JS.InvokeVoidAsync("lmeMap.search", $"{municipality.Name}, Italia"); var mapLocation = await LocationSearchService.SearchAsync($"{municipality.Name}, Italia", token);
if (mapLocation is not null)
{
await JS.InvokeVoidAsync("lmeMap.showSearchResult", mapLocation.Latitude, mapLocation.Longitude, mapLocation.Label);
}
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -392,7 +443,10 @@
try try
{ {
var label = $"{municipality.Name} - foglio {DisplaySheet(SelectedSheet)}, particella {parcel.Number}"; var label = $"{municipality.Name} - foglio {DisplaySheet(SelectedSheet)}, particella {parcel.Number}";
await JS.InvokeVoidAsync("lmeMap.locateParcelByPoint", parcel.Latitude, parcel.Longitude, parcel.Number, label); CatalogBusy = true;
CatalogMessage = "Scarico il confine dal servizio ufficiale AdE...";
var xml = await OfficialCadastreService.GetParcelsNearAsync(parcel.Latitude, parcel.Longitude, 8);
await JS.InvokeVoidAsync("lmeMap.displayOfficialParcel", xml, parcel.Latitude, parcel.Longitude, parcel.Number, label, true);
ActiveTab = "map"; ActiveTab = "map";
await Task.Delay(30); await Task.Delay(30);
await JS.InvokeVoidAsync("lmeMap.resize"); await JS.InvokeVoidAsync("lmeMap.resize");
@@ -402,6 +456,10 @@
CatalogMessage = $"Particella non visualizzata: {error.Message}"; CatalogMessage = $"Particella non visualizzata: {error.Message}";
await JS.InvokeVoidAsync("lmeMap.showInfo", CatalogMessage); await JS.InvokeVoidAsync("lmeMap.showInfo", CatalogMessage);
} }
finally
{
CatalogBusy = false;
}
} }
private async Task ToggleGps() private async Task ToggleGps()
@@ -419,11 +477,16 @@
private async Task StartGps() private async Task StartGps()
{ {
GpsActive = true; GpsActive = true;
GpsLookupCancellation?.Cancel();
GpsLookupCancellation?.Dispose();
GpsLookupCancellation = new CancellationTokenSource();
var lookupToken = GpsLookupCancellation.Token;
try try
{ {
await LocationService.StartTrackingAsync( await LocationService.StartTrackingAsync(
async location => await InvokeAsync(() => JS.InvokeVoidAsync("lmeMap.setNativeGpsPosition", location)), location => HandleGpsLocationAsync(location, lookupToken),
async error => await InvokeAsync(() => JS.InvokeVoidAsync("lmeMap.setGpsError", error))); async error => await InvokeAsync(() => JS.InvokeVoidAsync("lmeMap.setGpsError", error)));
GpsActive = LocationService.IsTracking;
} }
catch (Exception error) catch (Exception error)
{ {
@@ -435,10 +498,57 @@
private async Task StopGps() private async Task StopGps()
{ {
LocationService.StopTracking(); LocationService.StopTracking();
GpsLookupCancellation?.Cancel();
GpsLookupCancellation?.Dispose();
GpsLookupCancellation = null;
GpsActive = false; GpsActive = false;
await JS.InvokeVoidAsync("lmeMap.stopGps"); await JS.InvokeVoidAsync("lmeMap.stopGps");
} }
private async Task HandleGpsLocationAsync(LocationSnapshot location, CancellationToken cancellationToken)
{
await InvokeAsync(() => JS.InvokeVoidAsync("lmeMap.setNativeGpsPosition", location));
var now = Environment.TickCount64;
if (now - Interlocked.Read(ref LastGpsLookupAt) < 3_500
|| Interlocked.CompareExchange(ref GpsLookupInProgress, 1, 0) != 0)
{
return;
}
Interlocked.Exchange(ref LastGpsLookupAt, now);
try
{
var radius = Math.Clamp(location.Accuracy ?? 5, 2, 25);
var xml = await OfficialCadastreService.GetParcelsNearAsync(
location.Latitude,
location.Longitude,
radius,
cancellationToken);
await InvokeAsync(() => JS.InvokeVoidAsync(
"lmeMap.displayOfficialParcel",
xml,
location.Latitude,
location.Longitude,
null,
"Posizione GPS",
false));
}
catch (OperationCanceledException)
{
}
catch (Exception error)
{
await InvokeAsync(() => JS.InvokeVoidAsync(
"lmeMap.showInfo",
$"Aggiornamento particella GPS non disponibile: {error.GetBaseException().Message}"));
}
finally
{
Interlocked.Exchange(ref GpsLookupInProgress, 0);
}
}
private IProgress<CatalogProgress> CreateProgress() => new Progress<CatalogProgress>(progress => private IProgress<CatalogProgress> CreateProgress() => new Progress<CatalogProgress>(progress =>
{ {
_ = InvokeAsync(() => _ = InvokeAsync(() =>
@@ -472,18 +582,72 @@
private Task FitImportedLayers() => JS.InvokeVoidAsync("lmeMap.fitImported").AsTask(); private Task FitImportedLayers() => JS.InvokeVoidAsync("lmeMap.fitImported").AsTask();
private Task ClearImportedLayers() => JS.InvokeVoidAsync("lmeMap.clearImported").AsTask(); private Task ClearImportedLayers() => JS.InvokeVoidAsync("lmeMap.clearImported").AsTask();
private Task AddProjectMarker() => JS.InvokeVoidAsync("lmeMap.addProjectMarker", ProjectName).AsTask(); private Task AddProjectMarker() => JS.InvokeVoidAsync("lmeMap.addProjectMarker", ProjectName).AsTask();
private async Task EnableDrawTools() private async Task ToggleDrawTools()
{
DrawToolsVisible = !DrawToolsVisible;
await JS.InvokeVoidAsync("lmeMap.setDrawToolsVisible", DrawToolsVisible);
if (DrawToolsVisible)
{ {
await JS.InvokeVoidAsync("lmeMap.enableDrawTools");
await SelectTab("map"); await SelectTab("map");
} }
}
private Task ClearDrawings() => JS.InvokeVoidAsync("lmeMap.clearDrawings").AsTask(); private Task ClearDrawings() => JS.InvokeVoidAsync("lmeMap.clearDrawings").AsTask();
private Task SaveWorkspace() => JS.InvokeVoidAsync("lmeMap.saveWorkspace").AsTask(); private Task SaveWorkspace() => JS.InvokeVoidAsync("lmeMap.saveWorkspace").AsTask();
private Task LoadWorkspace() => JS.InvokeVoidAsync("lmeMap.loadWorkspace").AsTask(); private Task LoadWorkspace() => JS.InvokeVoidAsync("lmeMap.loadWorkspace").AsTask();
private Task CenterOnGps() => JS.InvokeVoidAsync("lmeMap.centerOnGps").AsTask(); private Task CenterOnGps() => JS.InvokeVoidAsync("lmeMap.centerOnGps").AsTask();
private Task ToggleTrail(ChangeEventArgs args) => JS.InvokeVoidAsync("lmeMap.toggleTrail", (bool?)args.Value == true).AsTask(); private async Task ToggleTrail(ChangeEventArgs args)
private Task ChangeBaseLayer(ChangeEventArgs args) => JS.InvokeVoidAsync("lmeMap.setBaseLayer", args.Value?.ToString()).AsTask(); {
TrailEnabled = (bool?)args.Value == true;
await JS.InvokeVoidAsync("lmeMap.toggleTrail", TrailEnabled);
}
private async Task ChangeBaseLayer(ChangeEventArgs args)
{
var requestedLayer = args.Value?.ToString() ?? "satellite";
try
{
if (requestedLayer.StartsWith("google-", StringComparison.Ordinal))
{
if (string.IsNullOrWhiteSpace(GoogleMapsApiKey))
{
await JS.InvokeVoidAsync("lmeMap.showInfo", "Inserisci e salva una chiave Google Maps Platform per usare questo sfondo.");
return;
}
await JS.InvokeVoidAsync("lmeMap.configureGoogleMaps", GoogleMapsApiKey.Trim());
}
await JS.InvokeVoidAsync("lmeMap.setBaseLayer", requestedLayer);
SelectedBaseLayer = requestedLayer;
}
catch (Exception error)
{
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Sfondo non disponibile: {error.GetBaseException().Message}");
}
}
private async Task SaveGoogleMapsApiKey()
{
try
{
if (string.IsNullOrWhiteSpace(GoogleMapsApiKey))
{
SecureStorage.Default.Remove("google_maps_api_key");
await JS.InvokeVoidAsync("lmeMap.showInfo", "Chiave Google rimossa.");
return;
}
GoogleMapsApiKey = GoogleMapsApiKey.Trim();
await SecureStorage.Default.SetAsync("google_maps_api_key", GoogleMapsApiKey);
await JS.InvokeVoidAsync("lmeMap.configureGoogleMaps", GoogleMapsApiKey);
await JS.InvokeVoidAsync("lmeMap.showInfo", "Chiave Google salvata sul dispositivo.");
}
catch (Exception error)
{
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Chiave Google non salvata: {error.Message}");
}
}
private Task ChangeCadastreOpacity(ChangeEventArgs args) => JS.InvokeVoidAsync("lmeMap.setCadastreOpacity", args.Value?.ToString()).AsTask(); private Task ChangeCadastreOpacity(ChangeEventArgs args) => JS.InvokeVoidAsync("lmeMap.setCadastreOpacity", args.Value?.ToString()).AsTask();
private Task ToggleCadastreLayer(string layer, ChangeEventArgs args) => JS.InvokeVoidAsync("lmeMap.toggleCadastreLayer", layer, (bool?)args.Value == true).AsTask(); private Task ToggleCadastreLayer(string layer, ChangeEventArgs args) => JS.InvokeVoidAsync("lmeMap.toggleCadastreLayer", layer, (bool?)args.Value == true).AsTask();
private Task ExportCadastralImage() => JS.InvokeVoidAsync("lmeMap.exportCadastreView").AsTask(); private Task ExportCadastralImage() => JS.InvokeVoidAsync("lmeMap.exportCadastreView").AsTask();
@@ -500,6 +664,8 @@
{ {
CancelCatalogWork(); CancelCatalogWork();
LocationService.StopTracking(); LocationService.StopTracking();
GpsLookupCancellation?.Cancel();
GpsLookupCancellation?.Dispose();
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
} }
+2 -2
View File
@@ -28,8 +28,8 @@
<ApplicationId>com.companyname.lmemappecatastaliapp</ApplicationId> <ApplicationId>com.companyname.lmemappecatastaliapp</ApplicationId>
<!-- Versions --> <!-- Versions -->
<ApplicationDisplayVersion>0.2.1</ApplicationDisplayVersion> <ApplicationDisplayVersion>0.2.2</ApplicationDisplayVersion>
<ApplicationVersion>5</ApplicationVersion> <ApplicationVersion>6</ApplicationVersion>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged --> <!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
<WindowsPackageType>None</WindowsPackageType> <WindowsPackageType>None</WindowsPackageType>
+2
View File
@@ -18,6 +18,8 @@ public static class MauiProgram
builder.Services.AddMauiBlazorWebView(); builder.Services.AddMauiBlazorWebView();
builder.Services.AddSingleton<PreciseLocationService>(); builder.Services.AddSingleton<PreciseLocationService>();
builder.Services.AddSingleton<CadastralCatalogService>(); builder.Services.AddSingleton<CadastralCatalogService>();
builder.Services.AddSingleton<OfficialCadastreService>();
builder.Services.AddSingleton<LocationSearchService>();
#if DEBUG #if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools(); builder.Services.AddBlazorWebViewDeveloperTools();
+3 -1
View File
@@ -4,7 +4,7 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata pe
## Funzioni gia implementate ## Funzioni gia implementate
- Mappa interattiva Leaflet con sfondo satellitare Esri e sfondo stradale OpenStreetMap. - Mappa interattiva Leaflet con sfondi Esri, OpenStreetMap, OpenTopoMap, CARTO Voyager e Google Maps configurabile tramite API key.
- Layer WMS ufficiale Agenzia delle Entrate: particelle, numeri particella, fogli, fabbricati, strade e acque. - Layer WMS ufficiale Agenzia delle Entrate: particelle, numeri particella, fogli, fabbricati, strade e acque.
- Trasparenza configurabile del layer catastale. - Trasparenza configurabile del layer catastale.
- Interrogazione WMS con click/tap sulla mappa. - Interrogazione WMS con click/tap sulla mappa.
@@ -20,8 +20,10 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata pe
- Mappa a tutto schermo con tab Catasto, Progetti e Impostazioni, adattabile agli schermi pieghevoli. - Mappa a tutto schermo con tab Catasto, Progetti e Impostazioni, adattabile agli schermi pieghevoli.
- Ingrandimento oltre lo zoom satellitare nativo senza tessere "Map data not yet available". - Ingrandimento oltre lo zoom satellitare nativo senza tessere "Map data not yet available".
- Tendine concatenate Regione, Comune, Foglio e Particella. - Tendine concatenate Regione, Comune, Foglio e Particella.
- Ricerca di localita e indirizzi eseguita nativamente con fallback Esri e OpenStreetMap, senza richieste CORS dalla WebView.
- Catalogo dei Comuni incluso nell'APK e cataloghi regionali OnData scaricati in cache solo quando servono. - Catalogo dei Comuni incluso nell'APK e cataloghi regionali OnData scaricati in cache solo quando servono.
- Ricerca catastale eseguita dal codice .NET nativo, senza DuckDB WebAssembly nella WebView Android. - Ricerca catastale eseguita dal codice .NET nativo, senza DuckDB WebAssembly nella WebView Android.
- La registrazione della traccia GPS e' disattivata all'avvio; la toolbar di disegno puo' essere mostrata o nascosta dal tab Progetti.
- Collegamenti separati al Geoportale pubblico e all'Area riservata AdE per CAPTCHA, SPID/CIE e download ufficiali. - Collegamenti separati al Geoportale pubblico e all'Area riservata AdE per CAPTCHA, SPID/CIE e download ufficiali.
## Limite ufficiale AdE da considerare ## Limite ufficiale AdE da considerare
+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);
}
}
}
+1
View File
@@ -28,6 +28,7 @@
<script src="_framework/blazor.webview.js" autostart="false"></script> <script src="_framework/blazor.webview.js" autostart="false"></script>
<script src="lib/leaflet/leaflet.js"></script> <script src="lib/leaflet/leaflet.js"></script>
<script src="lib/leaflet-draw/leaflet.draw.js"></script> <script src="lib/leaflet-draw/leaflet.draw.js"></script>
<script src="lib/googlemutant/Leaflet.GoogleMutant.js"></script>
<script src="lib/jszip/jszip.min.js"></script> <script src="lib/jszip/jszip.min.js"></script>
<script src="lib/togeojson/togeojson.umd.js"></script> <script src="lib/togeojson/togeojson.umd.js"></script>
<script src="js/lme-map.js"></script> <script src="js/lme-map.js"></script>
+154 -103
View File
@@ -8,33 +8,34 @@ const lmeState = {
imported: null, imported: null,
drawings: null, drawings: null,
drawControl: null, drawControl: null,
drawEventsBound: false,
gpsMarker: null, gpsMarker: null,
gpsAccuracy: null, gpsAccuracy: null,
gpsTrail: null, gpsTrail: null,
currentParcelLayer: null, currentParcelLayer: null,
gpsTrailEnabled: true, gpsTrailEnabled: false,
gpsTrailPoints: [], gpsTrailPoints: [],
gpsWatchId: null, gpsWatchId: null,
lastPosition: null, lastPosition: null,
lastWfsLookupAt: 0,
wfsLookupInFlight: false,
fileImportBound: false, fileImportBound: false,
resizeBound: false resizeBound: false,
googleMapsLoading: null
}; };
window.lmeMap = { window.lmeMap = {
init, init,
search, showSearchResult,
locateParcelByPoint, displayOfficialParcel,
exportCadastreView, exportCadastreView,
fitImported, fitImported,
clearImported, clearImported,
addProjectMarker, addProjectMarker,
enableDrawTools, setDrawToolsVisible,
clearDrawings, clearDrawings,
saveWorkspace, saveWorkspace,
loadWorkspace, loadWorkspace,
setBaseLayer, setBaseLayer,
configureGoogleMaps,
setCadastreOpacity, setCadastreOpacity,
toggleCadastreLayer, toggleCadastreLayer,
startGps, startGps,
@@ -70,6 +71,31 @@ function init() {
attribution: "Tiles &copy; Esri" attribution: "Tiles &copy; Esri"
}); });
lmeState.baseLayers.topographic = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", {
maxNativeZoom: 19,
maxZoom: 22,
attribution: "Tiles &copy; Esri"
});
lmeState.baseLayers["streets-esri"] = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}", {
maxNativeZoom: 19,
maxZoom: 22,
attribution: "Tiles &copy; Esri"
});
lmeState.baseLayers.terrain = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", {
maxNativeZoom: 17,
maxZoom: 22,
attribution: "Map data &copy; OpenStreetMap contributors, SRTM | Map style &copy; OpenTopoMap"
});
lmeState.baseLayers.carto = L.tileLayer("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", {
subdomains: "abcd",
maxNativeZoom: 20,
maxZoom: 22,
attribution: "&copy; OpenStreetMap contributors &copy; CARTO"
});
lmeState.currentBase = lmeState.baseLayers.satellite.addTo(lmeState.map); lmeState.currentBase = lmeState.baseLayers.satellite.addTo(lmeState.map);
lmeState.imported = L.featureGroup().addTo(lmeState.map); lmeState.imported = L.featureGroup().addTo(lmeState.map);
lmeState.drawings = L.featureGroup().addTo(lmeState.map); lmeState.drawings = L.featureGroup().addTo(lmeState.map);
@@ -95,35 +121,19 @@ function init() {
setInfo("Mappa pronta: satellite + catasto AdE"); setInfo("Mappa pronta: satellite + catasto AdE");
} }
async function search(text) { function showSearchResult(latitude, longitude, label) {
const query = (text || "").trim(); const lat = Number(latitude);
if (!query) { const lon = Number(longitude);
setInfo("Inserisci una localita o coordinate."); if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
setInfo("La ricerca ha restituito coordinate non valide.");
return; return;
} }
const coordinate = parseCoordinates(query); lmeState.map.setView([lat, lon], 18);
if (coordinate) { setInfo(label || `${lat.toFixed(6)}, ${lon.toFixed(6)}`);
lmeState.map.setView(coordinate, 19);
setInfo(`Coordinate: ${coordinate[0].toFixed(6)}, ${coordinate[1].toFixed(6)}`);
return;
}
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(query)}`, {
headers: { "Accept": "application/json" }
});
const results = await response.json();
if (!results.length) {
setInfo("Localita non trovata.");
return;
}
const place = results[0];
lmeState.map.setView([Number(place.lat), Number(place.lon)], 18);
setInfo(place.display_name);
} }
async function locateParcelByPoint(latitude, longitude, parcel, label) { function displayOfficialParcel(xmlText, latitude, longitude, parcel, label, shouldFit) {
const lat = Number(latitude); const lat = Number(latitude);
const lon = Number(longitude); const lon = Number(longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) { if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
@@ -131,21 +141,24 @@ async function locateParcelByPoint(latitude, longitude, parcel, label) {
return; return;
} }
setInfo(`Recupero il confine ufficiale: ${label}`);
try { try {
const meters = 8; const geojson = gmlParcelsToGeoJson(xmlText);
const deltaLat = meters / 111320;
const deltaLon = meters / (111320 * Math.cos(lat * Math.PI / 180));
const geojson = await fetchOfficialParcelsByBbox(lat - deltaLat, lon - deltaLon, lat + deltaLat, lon + deltaLon);
const normalizedParcel = normalizeParcel(parcel); const normalizedParcel = normalizeParcel(parcel);
const feature = (geojson.features || []).find(item => { const featureByNumber = normalizedParcel
? (geojson.features || []).find(item => {
const props = item.properties || {}; const props = item.properties || {};
return String(props.LABEL || "").trim().toUpperCase() === normalizedParcel.toUpperCase() return String(props.LABEL || "").trim().toUpperCase() === normalizedParcel
|| String(props.NATIONALCADASTRALREFERENCE || "").toUpperCase().endsWith(`.${normalizedParcel.toUpperCase()}`); || String(props.NATIONALCADASTRALREFERENCE || "").toUpperCase().endsWith(`.${normalizedParcel}`);
}) || (geojson.features || []).find(item => geometryContainsPoint(item.geometry, [lon, lat])) || geojson.features?.[0]; })
: null;
const feature = featureByNumber
|| (geojson.features || []).find(item => geometryContainsPoint(item.geometry, [lon, lat]))
|| geojson.features?.[0];
if (!feature) { if (!feature) {
if (shouldFit) {
lmeState.map.setView([lat, lon], 20); lmeState.map.setView([lat, lon], 20);
}
setInfo("Posizione trovata, ma il servizio WFS dell'Agenzia non ha restituito il confine. Riprova tra poco."); setInfo("Posizione trovata, ma il servizio WFS dell'Agenzia non ha restituito il confine. Riprova tra poco.");
return; return;
} }
@@ -154,13 +167,19 @@ async function locateParcelByPoint(latitude, longitude, parcel, label) {
const layer = L.geoJSON(feature, { const layer = L.geoJSON(feature, {
style: { color: "#ef4444", weight: 4, opacity: 1, fillColor: "#fbbf24", fillOpacity: 0.26 } style: { color: "#ef4444", weight: 4, opacity: 1, fillColor: "#fbbf24", fillOpacity: 0.26 }
}).addTo(lmeState.currentParcelLayer); }).addTo(lmeState.currentParcelLayer);
if (shouldFit) {
fitLayer(layer); fitLayer(layer);
}
const ref = feature.properties?.NATIONALCADASTRALREFERENCE || feature.properties?.INSPIREID_LOCALID || label; const ref = feature.properties?.NATIONALCADASTRALREFERENCE || feature.properties?.INSPIREID_LOCALID || label;
if (normalizedParcel) {
setText("parcelState", `Particella selezionata: ${ref}`); setText("parcelState", `Particella selezionata: ${ref}`);
setInfo(`Trovata ${ref}. Ora puoi attivare il GPS e camminare dentro il confine evidenziato.`); setInfo(`Trovata ${ref}. Ora puoi attivare il GPS e camminare dentro il confine evidenziato.`);
} else {
setText("parcelState", `Dentro particella AdE: ${ref}`);
}
} catch (error) { } catch (error) {
setInfo(`Servizio catastale temporaneamente non disponibile: ${error.message}`); setInfo(`Geometria catastale non leggibile: ${error.message}`);
} }
} }
@@ -284,13 +303,36 @@ function enableDrawTools() {
} }
}); });
lmeState.map.addControl(lmeState.drawControl); lmeState.map.addControl(lmeState.drawControl);
if (!lmeState.drawEventsBound) {
lmeState.map.on(L.Draw.Event.CREATED, event => { lmeState.map.on(L.Draw.Event.CREATED, event => {
lmeState.drawings.addLayer(event.layer); lmeState.drawings.addLayer(event.layer);
updateGpsInsideState(); updateGpsInsideState();
}); });
lmeState.drawEventsBound = true;
}
setInfo("Disegno attivo: crea POI, linee e aree sulla mappa."); setInfo("Disegno attivo: crea POI, linee e aree sulla mappa.");
} }
function setDrawToolsVisible(visible) {
if (visible) {
enableDrawTools();
return;
}
if (!lmeState.drawControl) {
return;
}
Object.values(lmeState.drawControl._toolbars || {}).forEach(toolbar => {
if (typeof toolbar.disable === "function") {
toolbar.disable();
}
});
lmeState.map.removeControl(lmeState.drawControl);
lmeState.drawControl = null;
setInfo("Strumenti di disegno nascosti.");
}
function clearDrawings() { function clearDrawings() {
const count = lmeState.drawings.getLayers().length; const count = lmeState.drawings.getLayers().length;
if (!count) { if (!count) {
@@ -343,6 +385,62 @@ function setBaseLayer(name) {
} }
lmeState.currentBase = selected.addTo(lmeState.map); lmeState.currentBase = selected.addTo(lmeState.map);
lmeState.currentBase.bringToBack(); lmeState.currentBase.bringToBack();
const labels = {
satellite: "Satellite Esri",
osm: "OpenStreetMap",
"streets-esri": "Strade dettagliate Esri",
topographic: "Topografica Esri",
terrain: "OpenTopoMap",
carto: "CARTO Voyager",
"google-satellite": "Satellite Google Maps",
"google-roadmap": "Strade Google Maps"
};
setInfo(`Sfondo attivo: ${labels[name] || labels.satellite}. Il catasto AdE resta sovrapposto.`);
}
function configureGoogleMaps(apiKey) {
const key = (apiKey || "").trim();
if (!key) {
return Promise.reject(new Error("Chiave Google Maps mancante."));
}
if (window.google?.maps && L.gridLayer.googleMutant) {
createGoogleLayers();
return Promise.resolve();
}
if (lmeState.googleMapsLoading) {
return lmeState.googleMapsLoading;
}
lmeState.googleMapsLoading = new Promise((resolve, reject) => {
const callbackName = `lmeGoogleMapsReady${Date.now()}`;
const script = document.createElement("script");
window[callbackName] = () => {
delete window[callbackName];
createGoogleLayers();
resolve();
};
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&loading=async&callback=${callbackName}`;
script.async = true;
script.onerror = () => {
delete window[callbackName];
lmeState.googleMapsLoading = null;
reject(new Error("Impossibile caricare Google Maps. Controlla chiave, API e connessione."));
};
document.head.appendChild(script);
});
return lmeState.googleMapsLoading;
}
function createGoogleLayers() {
if (!L.gridLayer.googleMutant) {
throw new Error("Modulo Google Maps non disponibile.");
}
lmeState.baseLayers["google-satellite"] ||= L.gridLayer.googleMutant({ type: "hybrid", maxZoom: 22 });
lmeState.baseLayers["google-roadmap"] ||= L.gridLayer.googleMutant({ type: "roadmap", maxZoom: 22 });
} }
function setCadastreOpacity(value) { function setCadastreOpacity(value) {
@@ -469,7 +567,6 @@ function onGpsPosition(position) {
setText("gpsState", `${latitude.toFixed(6)}, ${longitude.toFixed(6)} - precisione ${Math.round(accuracy)} m`); setText("gpsState", `${latitude.toFixed(6)}, ${longitude.toFixed(6)} - precisione ${Math.round(accuracy)} m`);
updateGpsInsideState(); updateGpsInsideState();
updateOfficialParcelFromPoint(latitude, longitude, accuracy);
} }
function onGpsError(error) { function onGpsError(error) {
@@ -483,6 +580,16 @@ function updateGpsInsideState() {
} }
const point = [lmeState.lastPosition[1], lmeState.lastPosition[0]]; const point = [lmeState.lastPosition[1], lmeState.lastPosition[0]];
const officialMatch = findContainingPolygon(point, lmeState.currentParcelLayer.toGeoJSON());
if (officialMatch) {
const reference = officialMatch.properties?.NATIONALCADASTRALREFERENCE
|| officialMatch.properties?.INSPIREID_LOCALID
|| officialMatch.properties?.LABEL
|| "particella AdE";
setText("parcelState", `Dentro particella AdE: ${reference}`);
return;
}
const match = findContainingPolygon(point, lmeState.imported.toGeoJSON()) || findContainingPolygon(point, lmeState.drawings.toGeoJSON()); const match = findContainingPolygon(point, lmeState.imported.toGeoJSON()) || findContainingPolygon(point, lmeState.drawings.toGeoJSON());
if (match) { if (match) {
@@ -491,51 +598,11 @@ function updateGpsInsideState() {
return; return;
} }
setText("parcelState", "Fuori dai poligoni caricati"); setText(
} "parcelState",
lmeState.currentParcelLayer.getLayers().length
async function updateOfficialParcelFromPoint(latitude, longitude, accuracy) { ? "Fuori dalla particella catastale evidenziata"
const now = Date.now(); : "Fuori dai poligoni caricati");
if (lmeState.wfsLookupInFlight || now - lmeState.lastWfsLookupAt < 2500) {
return;
}
lmeState.wfsLookupInFlight = true;
lmeState.lastWfsLookupAt = now;
const meters = Math.max(2, Math.min(Number(accuracy || 5), 25));
const deltaLat = meters / 111320;
const deltaLon = meters / (111320 * Math.cos(latitude * Math.PI / 180));
try {
const geojson = await fetchOfficialParcelsByBbox(latitude - deltaLat, longitude - deltaLon, latitude + deltaLat, longitude + deltaLon);
const feature = (geojson.features || []).find(item => geometryContainsPoint(item.geometry, [longitude, latitude])) || geojson.features?.[0];
if (!feature) {
return;
}
lmeState.currentParcelLayer.clearLayers();
L.geoJSON(feature, {
style: { color: "#ef4444", weight: 4, opacity: 1, fillColor: "#fbbf24", fillOpacity: 0.24 }
}).addTo(lmeState.currentParcelLayer);
const ref = feature.properties?.NATIONALCADASTRALREFERENCE || feature.properties?.INSPIREID_LOCALID || feature.properties?.LABEL || "particella AdE";
setText("parcelState", `Dentro particella AdE: ${ref}`);
} catch (error) {
setInfo(`Aggiornamento particella GPS non disponibile: ${error.message}`);
} finally {
lmeState.wfsLookupInFlight = false;
}
}
async function fetchOfficialParcelsByBbox(south, west, north, east) {
const url = `${wfsUrl()}language=ita&SERVICE=WFS&VERSION=2.0.0&TYPENAMES=CP:CadastralParcel&SRSNAME=urn:ogc:def:crs:EPSG::6706&BBOX=${south},${west},${north},${east}&REQUEST=GetFeature&COUNT=20`;
const response = await fetch(url);
if (!response.ok) {
return { type: "FeatureCollection", features: [] };
}
const xmlText = await response.text();
return gmlParcelsToGeoJson(xmlText);
} }
function gmlParcelsToGeoJson(xmlText) { function gmlParcelsToGeoJson(xmlText) {
@@ -634,18 +701,6 @@ async function queryFeatureInfo(latlng) {
} }
} }
function parseCoordinates(text) {
const parts = text.split(/[,\s;]+/).map(Number).filter(value => !Number.isNaN(value));
if (parts.length < 2) {
return null;
}
const [lat, lng] = parts;
if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) {
return [lat, lng];
}
return null;
}
function fitLayer(layer) { function fitLayer(layer) {
try { try {
const bounds = layer.getBounds(); const bounds = layer.getBounds();
@@ -661,10 +716,6 @@ function cadastreUrl() {
return "https://wms.cartografia.agenziaentrate.gov.it/inspire/wms/ows01.php?language=ita&"; return "https://wms.cartografia.agenziaentrate.gov.it/inspire/wms/ows01.php?language=ita&";
} }
function wfsUrl() {
return "https://wfs.cartografia.agenziaentrate.gov.it/inspire/wfs/owfs01.php?";
}
function setInfo(text) { function setInfo(text) {
setText("mapInfo", text); setText("mapInfo", text);
} }
@@ -0,0 +1,787 @@
this.L = this.L || {};
this.L.GridLayer = this.L.GridLayer || {};
this.L.GridLayer.GoogleMutant = (function (leaflet) {
"use strict";
/**
* GoogleMutant by Iván Sánchez Ortega <ivan@sanchezortega.es> https://ivan.sanchezortega.es
* Source and issue tracking: https://gitlab.com/IvanSanchez/Leaflet.GridLayer.GoogleMutant/
*
* Based on techniques from https://github.com/shramov/leaflet-plugins
* and https://avinmathew.com/leaflet-and-google-maps/ , but relying on MutationObserver.
*
* "THE BEER-WARE LICENSE":
* <ivan@sanchezortega.es> wrote this file. As long as you retain this notice you
* can do whatever you want with this stuff. If we meet some day, and you think
* this stuff is worth it, you can buy me a beer in return.
*
* Uses MIT-licensed code from https://github.com/rsms/js-lru/
*/
// This implementation of LRUMap is a copy of https://github.com/rsms/js-lru/ ,
// trivially adapted for ES6 exports.
/*
The MIT License
Copyright (c) 2010-2020 Rasmus Andersson <https://rsms.me/>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
/**
* A doubly linked list-based Least Recently Used (LRU) cache. Will keep most
* recently used items while discarding least recently used items when its limit
* is reached.
*
* Licensed under MIT. Copyright (c) 2010 Rasmus Andersson <http://hunch.se/>
* See README.md for details.
*
* Illustration of the design:
*
* entry entry entry entry
* ______ ______ ______ ______
* | head |.newer => | |.newer => | |.newer => | tail |
* | A | | B | | C | | D |
* |______| <= older.|______| <= older.|______| <= older.|______|
*
* removed <-- <-- <-- <-- <-- <-- <-- <-- <-- <-- <-- added
*/
const NEWER = Symbol("newer");
const OLDER = Symbol("older");
class LRUMap {
constructor(limit, entries) {
if (typeof limit !== "number") {
// called as (entries)
entries = limit;
limit = 0;
}
this.size = 0;
this.limit = limit;
this.oldest = this.newest = undefined;
this._keymap = new Map();
if (entries) {
this.assign(entries);
if (limit < 1) {
this.limit = this.size;
}
}
}
_markEntryAsUsed(entry) {
if (entry === this.newest) {
// Already the most recenlty used entry, so no need to update the list
return;
}
// HEAD--------------TAIL
// <.older .newer>
// <--- add direction --
// A B C <D> E
if (entry[NEWER]) {
if (entry === this.oldest) {
this.oldest = entry[NEWER];
}
entry[NEWER][OLDER] = entry[OLDER]; // C <-- E.
}
if (entry[OLDER]) {
entry[OLDER][NEWER] = entry[NEWER]; // C. --> E
}
entry[NEWER] = undefined; // D --x
entry[OLDER] = this.newest; // D. --> E
if (this.newest) {
this.newest[NEWER] = entry; // E. <-- D
}
this.newest = entry;
}
assign(entries) {
let entry,
limit = this.limit || Number.MAX_VALUE;
this._keymap.clear();
let it = entries[Symbol.iterator]();
for (let itv = it.next(); !itv.done; itv = it.next()) {
let e = new Entry(itv.value[0], itv.value[1]);
this._keymap.set(e.key, e);
if (!entry) {
this.oldest = e;
} else {
entry[NEWER] = e;
e[OLDER] = entry;
}
entry = e;
if (limit-- == 0) {
throw new Error("overflow");
}
}
this.newest = entry;
this.size = this._keymap.size;
}
get(key) {
// First, find our cache entry
var entry = this._keymap.get(key);
if (!entry) return; // Not cached. Sorry.
// As <key> was found in the cache, register it as being requested recently
this._markEntryAsUsed(entry);
return entry.value;
}
set(key, value) {
var entry = this._keymap.get(key);
if (entry) {
// update existing
entry.value = value;
this._markEntryAsUsed(entry);
return this;
}
// new entry
this._keymap.set(key, (entry = new Entry(key, value)));
if (this.newest) {
// link previous tail to the new tail (entry)
this.newest[NEWER] = entry;
entry[OLDER] = this.newest;
} else {
// we're first in -- yay
this.oldest = entry;
}
// add new entry to the end of the linked list -- it's now the freshest entry.
this.newest = entry;
++this.size;
if (this.size > this.limit) {
// we hit the limit -- remove the head
this.shift();
}
return this;
}
shift() {
// todo: handle special case when limit == 1
var entry = this.oldest;
if (entry) {
if (this.oldest[NEWER]) {
// advance the list
this.oldest = this.oldest[NEWER];
this.oldest[OLDER] = undefined;
} else {
// the cache is exhausted
this.oldest = undefined;
this.newest = undefined;
}
// Remove last strong reference to <entry> and remove links from the purged
// entry being returned:
entry[NEWER] = entry[OLDER] = undefined;
this._keymap.delete(entry.key);
--this.size;
return [entry.key, entry.value];
}
}
// -------------------------------------------------------------------------------------
// Following code (until end of class definition) is optional and can be removed without
// breaking the core functionality.
find(key) {
let e = this._keymap.get(key);
return e ? e.value : undefined;
}
has(key) {
return this._keymap.has(key);
}
delete(key) {
var entry = this._keymap.get(key);
if (!entry) return;
this._keymap.delete(entry.key);
if (entry[NEWER] && entry[OLDER]) {
// relink the older entry with the newer entry
entry[OLDER][NEWER] = entry[NEWER];
entry[NEWER][OLDER] = entry[OLDER];
} else if (entry[NEWER]) {
// remove the link to us
entry[NEWER][OLDER] = undefined;
// link the newer entry to head
this.oldest = entry[NEWER];
} else if (entry[OLDER]) {
// remove the link to us
entry[OLDER][NEWER] = undefined;
// link the newer entry to head
this.newest = entry[OLDER];
} else {
// if(entry[OLDER] === undefined && entry.newer === undefined) {
this.oldest = this.newest = undefined;
}
this.size--;
return entry.value;
}
clear() {
// Not clearing links should be safe, as we don't expose live links to user
this.oldest = this.newest = undefined;
this.size = 0;
this._keymap.clear();
}
keys() {
return new KeyIterator(this.oldest);
}
values() {
return new ValueIterator(this.oldest);
}
entries() {
return this;
}
[Symbol.iterator]() {
return new EntryIterator(this.oldest);
}
forEach(fun, thisObj) {
if (typeof thisObj !== "object") {
thisObj = this;
}
let entry = this.oldest;
while (entry) {
fun.call(thisObj, entry.value, entry.key, this);
entry = entry[NEWER];
}
}
/** Returns a JSON (array) representation */
toJSON() {
var s = new Array(this.size),
i = 0,
entry = this.oldest;
while (entry) {
s[i++] = { key: entry.key, value: entry.value };
entry = entry[NEWER];
}
return s;
}
/** Returns a String representation */
toString() {
var s = "",
entry = this.oldest;
while (entry) {
s += String(entry.key) + ":" + entry.value;
entry = entry[NEWER];
if (entry) {
s += " < ";
}
}
return s;
}
}
function Entry(key, value) {
this.key = key;
this.value = value;
this[NEWER] = undefined;
this[OLDER] = undefined;
}
function EntryIterator(oldestEntry) {
this.entry = oldestEntry;
}
EntryIterator.prototype[Symbol.iterator] = function () {
return this;
};
EntryIterator.prototype.next = function () {
let ent = this.entry;
if (ent) {
this.entry = ent[NEWER];
return { done: false, value: [ent.key, ent.value] };
} else {
return { done: true, value: undefined };
}
};
function KeyIterator(oldestEntry) {
this.entry = oldestEntry;
}
KeyIterator.prototype[Symbol.iterator] = function () {
return this;
};
KeyIterator.prototype.next = function () {
let ent = this.entry;
if (ent) {
this.entry = ent[NEWER];
return { done: false, value: ent.key };
} else {
return { done: true, value: undefined };
}
};
function ValueIterator(oldestEntry) {
this.entry = oldestEntry;
}
ValueIterator.prototype[Symbol.iterator] = function () {
return this;
};
ValueIterator.prototype.next = function () {
let ent = this.entry;
if (ent) {
this.entry = ent[NEWER];
return { done: false, value: ent.value };
} else {
return { done: true, value: undefined };
}
};
// GoogleMutant by Iván Sánchez Ortega <ivan@sanchezortega.es>
function waitForAPI(callback, context) {
let checkCounter = 0,
intervalId = null;
intervalId = setInterval(function () {
if (checkCounter >= 20) {
clearInterval(intervalId);
throw new Error("window.google not found after 10 seconds");
}
if (!!window.google && !!window.google.maps && !!window.google.maps.Map) {
clearInterval(intervalId);
callback.call(context);
}
++checkCounter;
}, 500);
}
// 🍂class GridLayer.GoogleMutant
// 🍂extends GridLayer
class GoogleMutant extends leaflet.GridLayer {
static {
// Try to set default option values the Leaflet 2 way
if (this.setDefaultOptions) {
this.setDefaultOptions({
maxZoom: 21, // can be 23, but ugly if more than maxNativeZoom
// 🍂option type: String = 'roadmap'
// Google's map type. Valid values are 'roadmap', 'satellite', 'terrain' or 'hybrid'.
type: "roadmap",
maxNativeZoom: 21,
});
} else {
// If Leaflet v2 is not available, fall back.
this.options ??= {};
this.options.maxZoom ??= 21;
this.options.type ??= "roadmap";
this.options.maxNativeZoom ??= 21;
}
}
constructor(options) {
super(options);
// Couple data structures indexed by tile key
this._tileCallbacks = {}; // Callbacks for promises for tiles that are expected
this._lru = new LRUMap(100); // Tile LRU cache
// Before 2024(ish), Google used two tiles for hybrid mode, reusing
// the aerial tiles. This seems to be no longer the case.
// this._imagesPerTile = this.options.type === "hybrid" ? 2 : 1;
this._imagesPerTile = 1;
this._boundOnMutatedImage = this._onMutatedImage.bind(this);
}
onAdd(map) {
super.onAdd(map);
this._initMutantContainer();
// Attribution and logo nodes are not mutated a second time if the
// mutant is removed and re-added to the map, hence they are
// not cleaned up on layer removal, so they can be added here.
if (this._logoContainer) {
map._controlCorners.bottomleft.appendChild(this._logoContainer);
}
if (this._attributionContainer) {
map._controlCorners.bottomright.appendChild(this._attributionContainer);
}
waitForAPI(() => {
if (!this._map) {
return;
}
this._initMutant();
//handle layer being added to a map for which there are no Google tiles at the given zoom
google.maps.event.addListenerOnce(this._mutant, "idle", () => {
if (!this._map) {
return;
}
this._checkZoomLevels();
this._mutantIsReady = true;
});
});
}
onRemove(map) {
super.onRemove(map);
this._observer.disconnect();
map._container.removeChild(this._mutantContainer);
this._logoContainer?.remove();
this._attributionContainer?.remove();
if (this._mutant) {
google.maps.event.clearListeners(this._mutant, "idle");
}
}
// 🍂method addGoogleLayer(name: String, options?: Object): this
// Adds layer with the given name and options to the google Map instance.
// `name`: one of the google maps API layers, with it's constructor available in `google.maps` object.
// currently following values supported: 'TrafficLayer', 'TransitLayer', 'BicyclingLayer'.
// `options`: see https://developers.google.com/maps/documentation/javascript/reference/map
addGoogleLayer(googleLayerName, options) {
if (!this._subLayers) this._subLayers = {};
this.whenReady(() => {
var Constructor = google.maps[googleLayerName];
var googleLayer = new Constructor(options);
googleLayer.setMap(this._mutant);
this._subLayers[googleLayerName] = googleLayer;
});
return this;
}
// 🍂method removeGoogleLayer(name: String): this
// Removes layer with the given name from the google Map instance.
removeGoogleLayer(googleLayerName) {
this.whenReady(() => {
var googleLayer = this._subLayers && this._subLayers[googleLayerName];
if (googleLayer) {
googleLayer.setMap(null);
delete this._subLayers[googleLayerName];
}
});
return this;
}
_initMutantContainer() {
if (!this._mutantContainer) {
this._mutantContainer = leaflet.DomUtil.create(
"div",
"leaflet-google-mutant leaflet-top leaflet-left"
);
this._mutantContainer.id =
"_MutantContainer_" + leaflet.Util.stamp(this._mutantContainer);
this._mutantContainer.style.pointerEvents = "none";
this._mutantContainer.style.visibility = "hidden";
leaflet.DomEvent.off(this._mutantContainer);
}
this._map.getContainer().appendChild(this._mutantContainer);
this.setOpacity(this.options.opacity);
const style = this._mutantContainer.style;
if (this._map.options.zoomSnap < 1) {
// Fractional zoom needs a bigger mutant container in order to load more (smaller) tiles
style.width = "180%";
style.height = "180%";
} else {
style.width = "100%";
style.height = "100%";
}
style.zIndex = -1;
this._attachObserver(this._mutantContainer);
}
_initMutant() {
if (this._mutant) {
return;
}
var options = {
center: { lat: 0, lng: 0 },
zoom: 0,
tilt: 0,
mapTypeId: this.options.type,
disableDefaultUI: true,
keyboardShortcuts: false,
draggable: false,
disableDoubleClickZoom: true,
scrollwheel: false,
styles: this.options.styles || [],
backgroundColor: "transparent",
};
if (this.options.mapId != null) {
options.mapId = this.options.mapId;
}
var map = new google.maps.Map(this._mutantContainer, options);
this._mutant = map;
this._update();
// 🍂event spawned
// Fired when the mutant has been created.
this.fire("spawned", { mapObject: map });
this._waitControls();
this.once("controls_ready", this._setupAttribution);
}
_attachObserver(node) {
if (!this._observer)
this._observer = new MutationObserver(this._onMutations.bind(this));
// pass in the target node, as well as the observer options
this._observer.observe(node, { childList: true, subtree: true });
// if we are reusing an old _mutantContainer, we must manually detect
// all existing tiles in it
Array.prototype.forEach.call(node.querySelectorAll("img"), this._boundOnMutatedImage);
}
_waitControls() {
const id = setInterval(() => {
const layoutManager = this._mutant.__gm.layoutManager;
if (!layoutManager) {
return;
}
clearInterval(id);
let positions;
// iterate through obfuscated key names to find positions set (atm: layoutManager.o)
Object.keys(layoutManager).forEach(function (key) {
const el = layoutManager[key];
if (el.get) {
if (el.get(1) instanceof Node) {
positions = el;
}
}
});
// 🍂event controls_ready
// Fired when controls positions get available (passed in `positions` property).
this.fire("controls_ready", { positions });
}, 50);
}
_setupAttribution(ev) {
if (!this._map) {
return;
}
// https://developers.google.com/maps/documentation/javascript/reference/control#ControlPosition
const pos = google.maps.ControlPosition;
const ctr = (this._attributionContainer = ev.positions.get(pos.BOTTOM_RIGHT));
ctr.classList.add("leaflet-control", "leaflet-control-attribution");
leaflet.DomEvent.disableClickPropagation(ctr);
ctr.style.height = "14px";
this._map._controlCorners.bottomright.appendChild(ctr);
this._logoContainer = ev.positions.get(pos.BOTTOM_LEFT);
this._logoContainer.style.pointerEvents = "auto";
this._map._controlCorners.bottomleft.appendChild(this._logoContainer);
}
_onMutations(mutations) {
for (var i = 0; i < mutations.length; ++i) {
var mutation = mutations[i];
for (var j = 0; j < mutation.addedNodes.length; ++j) {
var node = mutation.addedNodes[j];
if (node instanceof HTMLImageElement) {
this._onMutatedImage(node);
} else if (node instanceof HTMLElement) {
Array.prototype.forEach.call(
node.querySelectorAll("img"),
this._boundOnMutatedImage
);
}
}
}
}
// Only images which 'src' attrib match this will be considered for moving around.
// Looks like some kind of string-based protobuf, maybe??
// Only the roads (and terrain, and vector-based stuff) match this pattern
_roadRegexp = /!1i(\d+)!2i(\d+)!3i(\d+|VinaFnapurmBegrtn)!/;
// On the other hand, raster imagery matches this other pattern
_satRegexp = /x=(\d+)&y=(\d+)&z=(\d+|VinaFnapurmBegrtn)/;
_onMutatedImage(imgNode) {
let coords;
let match = imgNode.src.match(this._roadRegexp);
let sublayer = 0;
if (match) {
coords = {
z: match[1],
x: match[2],
y: match[3],
};
if (this._imagesPerTile > 1) {
imgNode.style.zIndex = 1;
sublayer = 1;
}
} else {
match = imgNode.src.match(this._satRegexp);
if (match) {
coords = {
x: match[1],
y: match[2],
z: match[3],
};
}
// imgNode.style.zIndex = 0;
sublayer = 0;
}
if (coords) {
var tileKey = this._tileCoordsToKey(coords);
imgNode.style.position = "absolute";
var key = tileKey + "/" + sublayer;
// Cache img so it can also be used in subsequent tile requests
this._lru.set(key, imgNode);
if (key in this._tileCallbacks && this._tileCallbacks[key]) {
// Use the tile for *all* pending callbacks. They'll be cloned anyway.
this._tileCallbacks[key].forEach((callback) => callback(imgNode));
delete this._tileCallbacks[key];
}
}
}
createTile(coords, done) {
const key = this._tileCoordsToKey(coords),
tileContainer = leaflet.DomUtil.create("div");
tileContainer.style.textAlign = "left";
tileContainer.dataset.pending = this._imagesPerTile;
done = done.bind(this, null, tileContainer);
for (var i = 0; i < this._imagesPerTile; ++i) {
const key2 = key + "/" + i,
imgNode = this._lru.get(key2);
if (imgNode) {
tileContainer.appendChild(this._clone(imgNode));
--tileContainer.dataset.pending;
} else {
this._tileCallbacks[key2] = this._tileCallbacks[key2] || [];
this._tileCallbacks[key2].push(
function (c /*, k2*/) {
return function (imgNode) {
c.appendChild(this._clone(imgNode));
--c.dataset.pending;
if (!parseInt(c.dataset.pending)) {
done();
}
}.bind(this);
}.bind(this)(tileContainer /*, key2*/)
);
}
}
if (!parseInt(tileContainer.dataset.pending)) {
leaflet.Util.requestAnimFrame(done);
}
return tileContainer;
}
_clone(imgNode) {
const clonedImgNode = imgNode.cloneNode(true);
clonedImgNode.style.visibility = "visible";
return clonedImgNode;
}
_checkZoomLevels() {
//setting the zoom level on the Google map may result in a different zoom level than the one requested
//(it won't go beyond the level for which they have data).
const zoomLevel = this._map.getZoom(),
gMapZoomLevel = this._mutant.getZoom();
if (!zoomLevel || !gMapZoomLevel) return;
if (
gMapZoomLevel !== zoomLevel || //zoom levels are out of sync, Google doesn't have data
gMapZoomLevel > this.options.maxNativeZoom
) {
//at current location, Google does have data (contrary to maxNativeZoom)
//Update maxNativeZoom
this._setMaxNativeZoom(gMapZoomLevel);
}
}
_setMaxNativeZoom(zoomLevel) {
if (zoomLevel !== this.options.maxNativeZoom) {
this.options.maxNativeZoom = zoomLevel;
this._resetView();
}
}
_update(center) {
// zoom level check needs to happen before super's implementation (tile addition/creation)
// otherwise tiles may be missed if maxNativeZoom is not yet correctly determined
if (this._mutant) {
center = center || this._map.getCenter();
const _center = new google.maps.LatLng(center.lat, center.lng),
zoom = Math.round(this._map.getZoom()),
mutantZoom = this._mutant.getZoom();
this._mutant.setCenter(_center);
//ignore fractional zoom levels
if (zoom !== mutantZoom) {
this._mutant.setZoom(zoom);
if (this._mutantIsReady) this._checkZoomLevels();
//else zoom level check will be done later by 'idle' handler
}
}
super._update(center);
}
// @method whenReady(fn: Function, context?: Object): this
// Runs the given function `fn` when the mutant gets initialized, or immediately
// if it's already initialized, optionally passing a function context.
whenReady(callback, context) {
if (this._mutant) {
callback.call(context || this, { target: this });
} else {
this.on("spawned", callback, context);
}
return this;
}
}
// 🍂factory gridLayer.googleMutant(options)
// Returns a new `GridLayer.GoogleMutant` given its options
if (L && L.gridLayer) {
L.gridLayer.googleMutant = function (options) {
return new L.GridLayer.GoogleMutant(options);
};
}
return GoogleMutant;
})(L);
//# sourceMappingURL=Leaflet.GoogleMutant.js.map