Compare commits

...
6 Commits
16 changed files with 11129 additions and 507 deletions

No files matched your search

+714 -111
View File
@@ -1,158 +1,361 @@
@page "/" @page "/"
@inject IJSRuntime JS @inject IJSRuntime JS
@inject PreciseLocationService LocationService @inject PreciseLocationService LocationService
@inject CadastralCatalogService CatalogService
@inject OfficialCadastreService OfficialCadastreService
@inject LocationSearchService LocationSearchService
@inject AzureMapsService AzureMapsService
@implements IAsyncDisposable @implements IAsyncDisposable
<PageTitle>LME Mappe Catastali</PageTitle> <PageTitle>LME Mappe Catastali</PageTitle>
<div class="map-app"> <div class="map-app">
<aside class="tool-panel"> <section class="map-stage">
<section class="brand"> <div id="map"></div>
<header class="map-toolbar">
<div class="app-identity">
<strong>LME Catasto</strong>
<span>@Status</span>
</div>
<div class="toolbar-actions">
<button class="toolbar-button layer-button @(LayerPanelOpen ? "is-active" : null)"
title="Layer catastali"
aria-expanded="@LayerPanelOpen"
@onclick="ToggleLayerPanel">
<span class="layer-symbol" aria-hidden="true"></span>
<span>Layer</span>
</button>
<button class="toolbar-button" @onclick='() => SelectTab(ActiveTab == "search" ? "map" : "search")'>Cerca</button>
</div>
</header>
<div class="map-search @(ActiveTab == "search" ? "is-open" : null)">
<input @bind="SearchText" @bind:event="oninput" placeholder="Localita, indirizzo o coordinate" />
<button @onclick="SearchLocation">Vai</button>
</div>
@if (LayerPanelOpen)
{
<section class="map-layer-panel" aria-label="Layer catastali">
<header>
<strong>Layer catastali</strong>
<button class="layer-panel-close" aria-label="Chiudi layer" @onclick="ToggleLayerPanel">×</button>
</header>
@foreach (var layer in CadastralLayers)
{
<label class="map-layer-option">
<input type="checkbox" checked="@layer.Enabled" @onchange="(args) => ToggleCadastreLayer(layer, args)" />
<span>
<strong>@layer.Name</strong>
@if (!string.IsNullOrWhiteSpace(layer.Detail))
{
<small>@layer.Detail</small>
}
</span>
</label>
}
<div class="layer-panel-actions">
<button class="secondary-command" @onclick="ClearSelectedParcels">Rimuovi particelle selezionate</button>
<button class="danger-command" @onclick="ClearMapElements">Pulisci elementi mappa</button>
</div>
</section>
}
<div class="gps-overlay @(ActiveTab == "search" ? "search-open" : null)">
<div class="gps-state">
<span id="gpsState">GPS non attivo</span>
<strong id="parcelState">Nessuna particella selezionata</strong>
</div>
<button class="gps-main" @onclick="ToggleGps">@(GpsActive ? "Ferma GPS" : "Avvia GPS")</button>
<button class="gps-center" @onclick="CenterOnGps" aria-label="Centra posizione">Centra</button>
</div>
@if (ActiveTab is not "map" and not "search")
{
<aside class="tool-sheet">
<div class="sheet-header">
<div> <div>
<p class="eyebrow">Catasto, rilievi e progetti</p> <p class="eyebrow">@PanelEyebrow</p>
<h1>LME Mappe Catastali</h1> <h1>@PanelTitle</h1>
</div> </div>
<span class="status">@Status</span> <button class="close-button" @onclick='() => SelectTab("map")' aria-label="Chiudi pannello">×</button>
</section>
<section class="panel-section">
<h2>Ricerca libera</h2>
<div class="input-row">
<input @bind="SearchText" @bind:event="oninput" placeholder="Indirizzo, localita, coordinate..." />
<button @onclick="SearchLocation">Cerca</button>
</div> </div>
</section>
<section class="panel-section"> @if (ActiveTab == "cadastre")
<h2>Particella catastale</h2> {
<div class="field-grid"> <section class="panel-content">
<label> <label>
Regione Regione
<input @bind="CadastralRegion" placeholder="Lombardia" /> <select value="@SelectedRegionFile" @onchange="OnRegionChanged">
<option value="">Seleziona regione</option>
@foreach (var region in Regions)
{
<option value="@region.DataFile">@region.Name</option>
}
</select>
</label> </label>
<label> <label>
Comune Comune
<input @bind="CadastralMunicipality" placeholder="Milano" /> <select value="@SelectedMunicipalityCode" @onchange="OnMunicipalityChanged" disabled="@(Municipalities.Count == 0)">
</label> <option value="">Seleziona comune</option>
<label> @foreach (var municipality in Municipalities)
Codice catastale {
<input @bind="CadastralMunicipalityCode" placeholder="F205" /> <option value="@municipality.Code">@municipality.Name (@municipality.Code)</option>
}
</select>
</label> </label>
<label> <label>
Foglio Foglio
<input @bind="CadastralSheet" placeholder="12" /> <select value="@SelectedSheet" @onchange="OnSheetChanged" disabled="@(Sheets.Count == 0 || CatalogBusy)">
<option value="">Seleziona foglio</option>
@foreach (var sheet in Sheets)
{
<option value="@sheet">@DisplaySheet(sheet)</option>
}
</select>
</label> </label>
<label> <label>
Particella Particella
<input @bind="CadastralParcel" placeholder="345" /> <select value="@SelectedParcelNumber" @onchange="OnParcelChanged" disabled="@(Parcels.Count == 0 || CatalogBusy)">
<option value="">Seleziona particella</option>
@foreach (var parcel in Parcels)
{
<option value="@parcel.Number">@parcel.Number</option>
}
</select>
</label> </label>
</div>
<div class="button-row">
<button @onclick="LocateParcel">Vai alla zona</button>
<button @onclick="ExportCadastralImage">Scarica vista</button>
</div>
<p class="hint">Il layer ufficiale WMS dell'Agenzia delle Entrate e' attivo; la selezione geometrica precisa della particella sara' collegata al servizio WFS/API.</p>
</section>
<section class="panel-section"> @if (!string.IsNullOrWhiteSpace(CatalogMessage))
{
<div class="catalog-progress">
<span>@CatalogMessage</span>
@if (CatalogFraction.HasValue)
{
<progress value="@CatalogFraction.Value" max="1"></progress>
}
</div>
}
<button class="primary-command" @onclick="LocateParcel" disabled="@(SelectedParcel is null || CatalogBusy)">Mostra particella sulla mappa</button>
<div class="service-note">
<strong>Mappa pubblica ufficiale</strong>
<p>I confini visualizzati arrivano dai servizi WMS/WFS open data dell'Agenzia delle Entrate e non richiedono CAPTCHA.</p>
</div>
</section>
}
else if (ActiveTab == "projects")
{
<section class="panel-content">
<h2>File GIS</h2> <h2>File GIS</h2>
<label class="file-picker"> <label class="file-picker">
<span>Carica KML, KMZ o GeoJSON</span> <span>Carica KML, KMZ o GeoJSON</span>
<input id="gisFileInput" type="file" accept=".kml,.kmz,.geojson,.json,application/vnd.google-earth.kml+xml,application/vnd.google-earth.kmz,application/geo+json" /> <input id="gisFileInput" type="file" accept=".kml,.kmz,.geojson,.json,application/vnd.google-earth.kml+xml,application/vnd.google-earth.kmz,application/geo+json" />
</label> </label>
<div class="button-row"> <div class="button-row">
<button @onclick="FitImportedLayers">Inquadra file</button> <button @onclick="FitImportedLayers">Inquadra</button>
<button @onclick="ClearImportedLayers">Pulisci file</button> <button class="secondary-command" @onclick="ClearImportedLayers">Pulisci</button>
</div> </div>
</section>
<section class="panel-section">
<h2>Progetti e POI</h2> <h2>Progetti e POI</h2>
<div class="input-row"> <label>
Nome
<input @bind="ProjectName" @bind:event="oninput" placeholder="Nome progetto o POI" /> <input @bind="ProjectName" @bind:event="oninput" placeholder="Nome progetto o POI" />
<button @onclick="AddProjectMarker">Aggiungi</button> </label>
</div> <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>
<button class="danger-command" @onclick="ClearDrawings">Elimina disegni e POI</button>
</section> </section>
}
<section class="panel-section live-section"> else if (ActiveTab == "settings")
<h2>GPS dal vivo</h2> {
<div class="gps-readout"> <section class="panel-content">
<span id="gpsState">GPS non attivo</span> <h2>Visualizzazione</h2>
<strong id="parcelState">Fuori da poligoni caricati</strong>
</div>
<div class="button-row">
<button @onclick="StartGps">Segui posizione</button>
<button @onclick="StopGps">Stop</button>
<button @onclick="CenterOnGps">Centra</button>
</div>
<label class="toggle-line">
<input type="checkbox" checked @onchange="ToggleTrail" />
Registra percorso
</label>
</section>
<section class="panel-section">
<h2>Livelli</h2>
<div class="field-grid compact">
<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="azure-road">Strade - Azure Maps</option>
<option value="azure-satellite">Satellite - Azure Maps</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>
Chiave sottoscrizione Azure Maps Gen2
<input type="password" @bind="AzureMapsSubscriptionKey" placeholder="Subscription key" autocomplete="off" />
</label>
<button class="secondary-command" @onclick="SaveAzureMapsKey">Salva chiave Azure Maps</button>
<div class="service-note">
<strong>Quota gratuita Azure Maps Gen2</strong>
<p>Include ogni mese 5.000 transazioni per le mappe stradali e 1.000 per le immagini satellitari. Ogni transazione comprende fino a 15 tessere.</p>
</div>
<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" />
</label> </label>
</div> <h2>GPS e percorso</h2>
<div class="layer-toggles"> <label class="toggle-line">
@foreach (var layer in CadastralLayers) <input type="checkbox" checked="@TrailEnabled" @onchange="ToggleTrail" />
{ Registra la traccia mentre cammini
<label>
<input type="checkbox" checked="@layer.Enabled" @onchange="(args) => ToggleCadastreLayer(layer.Key, args)" />
@layer.Name
</label> </label>
<h2>Servizi Agenzia delle Entrate</h2>
<div class="service-note protected-service">
<strong>Download documenti e fogli ufficiali</strong>
<p>Il CAPTCHA o l'accesso SPID/CIE vengono gestiti direttamente dal sito dell'Agenzia. Per sicurezza l'app non tenta di aggirarli.</p>
<button class="primary-command" @onclick="OpenOfficialGeoportal">Apri Geoportale catastale</button>
<button class="secondary-command" @onclick="OpenOfficialReservedArea">Apri Area riservata per download</button>
</div>
<button class="secondary-command" @onclick="ExportCadastralImage">Mostra vista WMS corrente</button>
<footer>Dati catastali: Agenzia delle Entrate, licenza CC BY 4.0. App indipendente.</footer>
</section>
} }
</aside>
}
<div id="mapInfo" class="map-info">Pronto</div>
<div id="wmsPreview" class="wms-preview" hidden>
<section class="wms-preview-dialog" role="dialog" aria-modal="true" aria-labelledby="wmsPreviewTitle">
<header>
<div>
<strong id="wmsPreviewTitle">Vista WMS catastale</strong>
<small>Immagine ufficiale AdE con le particelle evidenziate dall'app</small>
</div>
<button id="closeWmsPreview" class="close-button" aria-label="Chiudi vista WMS">×</button>
</header>
<div class="wms-preview-body">
<span id="wmsPreviewLoading">Caricamento...</span>
<div id="wmsPreviewMap" class="wms-preview-map" hidden>
<img id="wmsPreviewImage" alt="Vista WMS catastale dell'area corrente" />
<svg id="wmsPreviewOverlay" aria-hidden="true" preserveAspectRatio="none"></svg>
</div>
</div> </div>
</section> </section>
</div>
<footer> <nav class="bottom-tabs" aria-label="Sezioni principali">
Dati catastali: Agenzia delle Entrate, licenza CC BY 4.0. App indipendente. <button class="@(ActiveTab == "map" || ActiveTab == "search" ? "active" : null)" @onclick='() => SelectTab("map")'>Mappa</button>
</footer> <button class="@(ActiveTab == "cadastre" ? "active" : null)" @onclick='() => SelectTab("cadastre")'>Catasto</button>
</aside> <button class="@(ActiveTab == "projects" ? "active" : null)" @onclick='() => SelectTab("projects")'>Progetti</button>
<button class="@(ActiveTab == "settings" ? "active" : null)" @onclick='() => SelectTab("settings")'>Impostazioni</button>
<section class="map-stage"> </nav>
<div id="map"></div>
<div id="mapInfo" class="map-info">Pronto</div>
</section> </section>
</div> </div>
@code { @code {
private string SearchText { get; set; } = ""; private string ActiveTab { get; set; } = "map";
private string CadastralRegion { get; set; } = ""; private string SearchText { get; set; } = string.Empty;
private string CadastralMunicipality { get; set; } = ""; private string ProjectName { get; set; } = string.Empty;
private string CadastralMunicipalityCode { get; set; } = "";
private string CadastralSheet { get; set; } = "";
private string CadastralParcel { get; set; } = "";
private string ProjectName { get; set; } = "";
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 AzureMapsSubscriptionKey { get; set; } = string.Empty;
private string SelectedRegionFile { get; set; } = string.Empty;
private string SelectedMunicipalityCode { get; set; } = string.Empty;
private string SelectedSheet { get; set; } = string.Empty;
private string SelectedParcelNumber { get; set; } = string.Empty;
private string CatalogMessage { get; set; } = string.Empty;
private double? CatalogFraction { get; set; }
private bool CatalogBusy { get; set; }
private bool GpsActive { get; set; }
private bool DrawToolsVisible { get; set; }
private bool TrailEnabled { get; set; }
private bool LayerPanelOpen { 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<MunicipalityOption> Municipalities { get; set; } = [];
private IReadOnlyList<string> Sheets { get; set; } = [];
private IReadOnlyList<ParcelOption> Parcels { get; set; } = [];
private MunicipalityOption? SelectedMunicipality =>
Municipalities.FirstOrDefault(item => item.Code == SelectedMunicipalityCode);
private ParcelOption? SelectedParcel =>
Parcels.FirstOrDefault(item => item.Number == SelectedParcelNumber);
private string PanelTitle => ActiveTab switch
{
"cadastre" => "Ricerca catastale",
"projects" => "Progetti e dati",
"settings" => "Impostazioni",
_ => string.Empty
};
private string PanelEyebrow => ActiveTab switch
{
"cadastre" => "Regione, Comune, Foglio, Particella",
"projects" => "KML, KMZ, GeoJSON e POI",
"settings" => "Mappa, GPS e servizi ufficiali",
_ => string.Empty
};
private readonly List<CadastralLayer> CadastralLayers = private readonly List<CadastralLayer> CadastralLayers =
[ [
new("CP.CadastralParcel", "Particelle", true), new("CP.CadastralParcel", "Confini particelle", true),
new("codice_plla", "Numeri particelle", true), new("codice_plla", "Numeri particelle", true, "Visibili circa da zoom 17"),
new("CP.CadastralZoning", "Fogli", true), new("CP.CadastralZoning", "Fogli catastali", true),
new("fabbricati", "Fabbricati", true), new("fabbricati", "Fabbricati", true),
new("strade", "Strade", false), new("strade", "Strade catastali", false),
new("acque", "Acque", false) new("acque", "Acque", false)
]; ];
protected override async Task OnInitializedAsync()
{
try
{
GoogleMapsApiKey = await SecureStorage.Default.GetAsync("google_maps_api_key") ?? string.Empty;
}
catch
{
GoogleMapsApiKey = string.Empty;
}
try
{
AzureMapsSubscriptionKey = await SecureStorage.Default.GetAsync("azure_maps_subscription_key") ?? string.Empty;
}
catch
{
AzureMapsSubscriptionKey = string.Empty;
}
try
{
Regions = await CatalogService.GetRegionsAsync();
}
catch (Exception error)
{
CatalogMessage = $"Catalogo Comuni non disponibile: {error.Message}";
}
}
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender)
@@ -161,55 +364,455 @@
} }
} }
private Task SearchLocation() => JS.InvokeVoidAsync("lmeMap.search", SearchText).AsTask(); private async Task SelectTab(string tab)
private Task LocateParcel()
{ {
var label = $"{CadastralRegion} {CadastralMunicipality} foglio {CadastralSheet} particella {CadastralParcel}".Trim(); ActiveTab = tab;
return JS.InvokeVoidAsync("lmeMap.locateParcelArea", CadastralRegion, CadastralMunicipality, CadastralMunicipalityCode, CadastralSheet, CadastralParcel, label).AsTask(); if (tab != "map")
{
LayerPanelOpen = false;
}
await Task.Delay(30);
await JS.InvokeVoidAsync("lmeMap.resize");
} }
private Task ExportCadastralImage() => JS.InvokeVoidAsync("lmeMap.exportCadastreView").AsTask(); private async Task ToggleLayerPanel()
{
LayerPanelOpen = !LayerPanelOpen;
if (LayerPanelOpen)
{
ActiveTab = "map";
}
private Task FitImportedLayers() => JS.InvokeVoidAsync("lmeMap.fitImported").AsTask(); await Task.Delay(30);
await JS.InvokeVoidAsync("lmeMap.resize");
}
private Task ClearImportedLayers() => JS.InvokeVoidAsync("lmeMap.clearImported").AsTask(); private async Task SearchLocation()
{
if (string.IsNullOrWhiteSpace(SearchText))
{
await JS.InvokeVoidAsync("lmeMap.showInfo", "Inserisci una localita, un indirizzo o due coordinate.");
return;
}
private Task AddProjectMarker() => JS.InvokeVoidAsync("lmeMap.addProjectMarker", ProjectName).AsTask(); try
{
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";
Status = "online";
}
catch (Exception error)
{
Status = "errore ricerca";
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Ricerca non riuscita: {error.Message}");
}
}
private async Task OnRegionChanged(ChangeEventArgs args)
{
CancelCatalogWork();
SelectedRegionFile = args.Value?.ToString() ?? string.Empty;
SelectedMunicipalityCode = string.Empty;
SelectedSheet = string.Empty;
SelectedParcelNumber = string.Empty;
Sheets = [];
Parcels = [];
CatalogMessage = string.Empty;
Municipalities = string.IsNullOrWhiteSpace(SelectedRegionFile)
? []
: await CatalogService.GetMunicipalitiesAsync(SelectedRegionFile);
}
private async Task OnMunicipalityChanged(ChangeEventArgs args)
{
SelectedMunicipalityCode = args.Value?.ToString() ?? string.Empty;
SelectedSheet = string.Empty;
SelectedParcelNumber = string.Empty;
Sheets = [];
Parcels = [];
var municipality = SelectedMunicipality;
if (municipality is null)
{
return;
}
var token = BeginCatalogWork();
CatalogBusy = true;
CatalogMessage = "Preparo il catalogo regionale...";
CatalogFraction = null;
try
{
Sheets = await CatalogService.GetSheetsAsync(municipality, CreateProgress(), token);
CatalogMessage = $"Seleziona uno dei {Sheets.Count} fogli disponibili.";
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 (Exception error)
{
CatalogMessage = $"Impossibile caricare i fogli: {error.Message}";
await JS.InvokeVoidAsync("lmeMap.showInfo", CatalogMessage);
}
finally
{
CatalogBusy = false;
CatalogFraction = null;
}
}
private async Task OnSheetChanged(ChangeEventArgs args)
{
SelectedSheet = args.Value?.ToString() ?? string.Empty;
SelectedParcelNumber = string.Empty;
Parcels = [];
var municipality = SelectedMunicipality;
if (municipality is null || string.IsNullOrWhiteSpace(SelectedSheet))
{
return;
}
var token = BeginCatalogWork();
CatalogBusy = true;
CatalogMessage = "Carico le particelle del foglio...";
CatalogFraction = null;
try
{
Parcels = await CatalogService.GetParcelsAsync(municipality, SelectedSheet, CreateProgress(), token);
CatalogMessage = $"Seleziona una delle {Parcels.Count} particelle disponibili.";
}
catch (OperationCanceledException)
{
}
catch (Exception error)
{
CatalogMessage = $"Impossibile caricare le particelle: {error.Message}";
await JS.InvokeVoidAsync("lmeMap.showInfo", CatalogMessage);
}
finally
{
CatalogBusy = false;
CatalogFraction = null;
}
}
private Task OnParcelChanged(ChangeEventArgs args)
{
SelectedParcelNumber = args.Value?.ToString() ?? string.Empty;
return Task.CompletedTask;
}
private async Task LocateParcel()
{
var municipality = SelectedMunicipality;
var parcel = SelectedParcel;
if (municipality is null || parcel is null)
{
return;
}
try
{
var label = $"{municipality.Name} - foglio {DisplaySheet(SelectedSheet)}, particella {parcel.Number}";
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";
await Task.Delay(30);
await JS.InvokeVoidAsync("lmeMap.resize");
}
catch (Exception error)
{
CatalogMessage = $"Particella non visualizzata: {error.Message}";
await JS.InvokeVoidAsync("lmeMap.showInfo", CatalogMessage);
}
finally
{
CatalogBusy = false;
}
}
private async Task ToggleGps()
{
if (GpsActive)
{
await StopGps();
}
else
{
await StartGps();
}
}
private async Task StartGps() private async Task StartGps()
{
GpsActive = true;
GpsLookupCancellation?.Cancel();
GpsLookupCancellation?.Dispose();
GpsLookupCancellation = new CancellationTokenSource();
var lookupToken = GpsLookupCancellation.Token;
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)
{
GpsActive = false;
await JS.InvokeVoidAsync("lmeMap.setGpsError", error.Message);
}
} }
private async Task StopGps() private async Task StopGps()
{ {
LocationService.StopTracking(); LocationService.StopTracking();
GpsLookupCancellation?.Cancel();
GpsLookupCancellation?.Dispose();
GpsLookupCancellation = null;
GpsActive = false;
await JS.InvokeVoidAsync("lmeMap.stopGps"); await JS.InvokeVoidAsync("lmeMap.stopGps");
} }
private Task CenterOnGps() => JS.InvokeVoidAsync("lmeMap.centerOnGps").AsTask(); private async Task HandleGpsLocationAsync(LocationSnapshot location, CancellationToken cancellationToken)
{
await InvokeAsync(() => JS.InvokeVoidAsync("lmeMap.setNativeGpsPosition", location));
private Task ToggleTrail(ChangeEventArgs args) => JS.InvokeVoidAsync("lmeMap.toggleTrail", (bool?)args.Value == true).AsTask(); var now = Environment.TickCount64;
if (now - Interlocked.Read(ref LastGpsLookupAt) < 3_500
|| Interlocked.CompareExchange(ref GpsLookupInProgress, 1, 0) != 0)
{
return;
}
private Task EnableDrawTools() => JS.InvokeVoidAsync("lmeMap.enableDrawTools").AsTask(); 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 =>
{
_ = InvokeAsync(() =>
{
CatalogMessage = progress.Message;
CatalogFraction = progress.Fraction;
StateHasChanged();
});
});
private CancellationToken BeginCatalogWork()
{
CancelCatalogWork();
CatalogCancellation = new CancellationTokenSource();
return CatalogCancellation.Token;
}
private void CancelCatalogWork()
{
CatalogCancellation?.Cancel();
CatalogCancellation?.Dispose();
CatalogCancellation = null;
}
private static string DisplaySheet(string value)
{
var trimmed = value.TrimStart('0');
return string.IsNullOrEmpty(trimmed) ? "0" : trimmed;
}
private Task FitImportedLayers() => JS.InvokeVoidAsync("lmeMap.fitImported").AsTask();
private Task ClearImportedLayers() => JS.InvokeVoidAsync("lmeMap.clearImported").AsTask();
private Task AddProjectMarker() => JS.InvokeVoidAsync("lmeMap.addProjectMarker", ProjectName).AsTask();
private async Task ToggleDrawTools()
{
DrawToolsVisible = !DrawToolsVisible;
await JS.InvokeVoidAsync("lmeMap.setDrawToolsVisible", DrawToolsVisible);
if (DrawToolsVisible)
{
await SelectTab("map");
}
}
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 async Task ToggleTrail(ChangeEventArgs args)
{
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;
}
private Task ChangeBaseLayer(ChangeEventArgs args) => JS.InvokeVoidAsync("lmeMap.setBaseLayer", args.Value?.ToString()).AsTask(); await JS.InvokeVoidAsync("lmeMap.configureGoogleMaps", GoogleMapsApiKey.Trim());
}
if (requestedLayer.StartsWith("azure-", StringComparison.Ordinal))
{
if (string.IsNullOrWhiteSpace(AzureMapsSubscriptionKey))
{
await JS.InvokeVoidAsync("lmeMap.showInfo", "Inserisci e salva la chiave della sottoscrizione Azure Maps.");
return;
}
await AzureMapsService.ValidateKeyAsync(AzureMapsSubscriptionKey);
await JS.InvokeVoidAsync("lmeMap.configureAzureMaps", AzureMapsSubscriptionKey.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 async Task SaveAzureMapsKey()
{
try
{
if (string.IsNullOrWhiteSpace(AzureMapsSubscriptionKey))
{
SecureStorage.Default.Remove("azure_maps_subscription_key");
await JS.InvokeVoidAsync("lmeMap.clearAzureMaps");
if (SelectedBaseLayer.StartsWith("azure-", StringComparison.Ordinal))
{
SelectedBaseLayer = "satellite";
}
await JS.InvokeVoidAsync("lmeMap.showInfo", "Chiave Azure Maps rimossa.");
return;
}
AzureMapsSubscriptionKey = AzureMapsSubscriptionKey.Trim();
await AzureMapsService.ValidateKeyAsync(AzureMapsSubscriptionKey);
await SecureStorage.Default.SetAsync("azure_maps_subscription_key", AzureMapsSubscriptionKey);
await JS.InvokeVoidAsync("lmeMap.configureAzureMaps", AzureMapsSubscriptionKey);
await JS.InvokeVoidAsync("lmeMap.showInfo", "Chiave Azure Maps valida e salvata sul dispositivo.");
}
catch (Exception error)
{
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Chiave Azure Maps non salvata: {error.GetBaseException().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 async Task ToggleCadastreLayer(CadastralLayer layer, ChangeEventArgs args)
private sealed record CadastralLayer(string Key, string Name, bool Enabled);
public async ValueTask DisposeAsync()
{ {
await LocationService.DisposeAsync(); layer.Enabled = args.Value is true;
await JS.InvokeVoidAsync("lmeMap.toggleCadastreLayer", layer.Key, layer.Enabled);
}
private async Task ExportCadastralImage()
{
ActiveTab = "map";
LayerPanelOpen = false;
await Task.Delay(30);
await JS.InvokeVoidAsync("lmeMap.resize");
await JS.InvokeVoidAsync("lmeMap.exportCadastreView");
}
private Task ClearSelectedParcels() => JS.InvokeVoidAsync("lmeMap.clearSelectedParcels").AsTask();
private Task ClearMapElements() => JS.InvokeVoidAsync("lmeMap.clearMapElements").AsTask();
private Task OpenOfficialGeoportal() => Microsoft.Maui.ApplicationModel.Launcher.Default.OpenAsync(
new Uri("https://geoportale.cartografia.agenziaentrate.gov.it/"));
private Task OpenOfficialReservedArea() => Microsoft.Maui.ApplicationModel.Launcher.Default.OpenAsync(
new Uri("https://www.agenziaentrate.gov.it/portale/area-riservata"));
private sealed class CadastralLayer(string key, string name, bool enabled, string detail = "")
{
public string Key { get; } = key;
public string Name { get; } = name;
public bool Enabled { get; set; } = enabled;
public string Detail { get; } = detail;
}
public ValueTask DisposeAsync()
{
CancelCatalogWork();
LocationService.StopTracking();
GpsLookupCancellation?.Cancel();
GpsLookupCancellation?.Dispose();
return ValueTask.CompletedTask;
} }
} }
+4 -3
View File
@@ -22,14 +22,14 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<!-- Display name --> <!-- Display name -->
<ApplicationTitle>LMEMappeCatastaliApp</ApplicationTitle> <ApplicationTitle>LME Mappe Catastali</ApplicationTitle>
<!-- App Identifier --> <!-- App Identifier -->
<ApplicationId>com.companyname.lmemappecatastaliapp</ApplicationId> <ApplicationId>com.companyname.lmemappecatastaliapp</ApplicationId>
<!-- Versions --> <!-- Versions -->
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion> <ApplicationDisplayVersion>0.2.5</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion> <ApplicationVersion>9</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>
@@ -64,6 +64,7 @@
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" /> <PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="$(MauiVersion)" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.9" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.9" />
<PackageReference Include="Parquet.Net" Version="6.1.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+4
View File
@@ -17,6 +17,10 @@ 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<OfficialCadastreService>();
builder.Services.AddSingleton<LocationSearchService>();
builder.Services.AddSingleton<AzureMapsService>();
#if DEBUG #if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools(); builder.Services.AddBlazorWebViewDeveloperTools();
+37 -7
View File
@@ -4,19 +4,32 @@ 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, Google Maps e Azure Maps configurabili tramite chiave.
- 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.
- Import di file KML, KMZ, GeoJSON e JSON. - Import di file KML, KMZ, GeoJSON e JSON.
- Inquadramento e pulizia dei layer importati. - Inquadramento e pulizia dei layer importati.
- Disegno di marker, linee, poligoni e rettangoli. - Disegno di marker, linee, poligoni e rettangoli.
- Toolbar di disegno completa di icone, modifica, eliminazione singola e pulizia di tutti i disegni/POI.
- POI/progetti salvabili in locale con `localStorage`. - POI/progetti salvabili in locale con `localStorage`.
- GPS nativo MAUI ad alta precisione con permessi Android/iOS. - GPS nativo MAUI ad alta precisione con permessi Android/iOS.
- Marker posizione, cerchio accuratezza in metri, tracciamento percorso. - Marker posizione, cerchio accuratezza in metri, tracciamento percorso.
- Controllo se la posizione GPS cade dentro un poligono importato o disegnato. - Controllo se la posizione GPS cade dentro un poligono importato o disegnato.
- Interrogazione WFS ufficiale Agenzia delle Entrate attorno alla posizione GPS per evidenziare la particella catastale corrente quando il servizio restituisce la geometria. - Interrogazione WFS ufficiale Agenzia delle Entrate attorno alla posizione GPS per evidenziare la particella catastale corrente quando il servizio restituisce la geometria.
- Ricerca Comune/Foglio/Particella tramite indice OnData e successivo recupero geometria dal WFS ufficiale AdE. - 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".
- 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.
- 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.
- Pannello `Layer` accessibile direttamente dalla mappa, con checkbox sincronizzati per confini, numeri particella, fogli, fabbricati, strade e acque.
- Le particelle cercate possono essere aggiunte una dopo l'altra, mantenendo contemporaneamente sulla mappa tutti i confini selezionati.
- Comandi separati per rimuovere le particelle selezionate o pulire importazioni, disegni, POI e traccia GPS.
- Anteprima WMS ufficiale mostrata dentro l'app senza aprire una nuova finestra Android.
- Confini delle particelle selezionate e della particella GPS sovrapposti anche all'anteprima WMS corrente.
- 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
@@ -26,7 +39,24 @@ Il WFS dell'Agenzia delle Entrate consente `GetFeature` con bounding box, ma non
2. una chiamata WFS con bounding box piccola intorno a quel punto; 2. una chiamata WFS con bounding box piccola intorno a quel punto;
3. filtro lato app sul risultato per selezionare la particella corretta. 3. filtro lato app sul risultato per selezionare la particella corretta.
Questo passaggio e' implementato nella schermata "Particella catastale". Per la massima affidabilita inserisci il codice catastale del Comune, ad esempio `F205` per Milano o `H501` per Roma. Questo passaggio e' implementato nel tab `Catasto`. Regione e Comune sono disponibili subito; alla prima selezione di un Comune l'app scarica il file della regione, da circa 6 a 72 MB, e lo conserva nella cache del dispositivo. Fogli e particelle vengono poi popolati automaticamente.
I layer cartografici pubblici WMS/WFS sono open data e non richiedono CAPTCHA. Il CAPTCHA o l'autenticazione SPID/CIE appartengono ai servizi web di consultazione e download documentale: l'app li apre nel sito ufficiale dell'Agenzia e non tenta di aggirarli.
## Azure Maps Gen2
Nel tab `Impostazioni` sono disponibili gli sfondi `Strade - Azure Maps` e `Satellite - Azure Maps`. Per usarli:
1. crea una risorsa Azure Maps con piano Gen2 nel portale Azure;
2. copia una delle chiavi di sottoscrizione della risorsa;
3. inseriscila in `Chiave sottoscrizione Azure Maps Gen2` e premi `Salva chiave Azure Maps`.
La chiave viene validata prima del salvataggio e rimane nel `SecureStorage` del dispositivo; non e' inclusa nel codice o nell'APK. Lasciando vuoto il campo e premendo il pulsante, la chiave viene rimossa.
La quota gratuita mensile Gen2 indicata da Microsoft comprende 5.000 transazioni per le mappe di base e 1.000 per le immagini satellitari. Una transazione raggruppa fino a 15 richieste di tessere. Oltre la quota si applicano le condizioni economiche della sottoscrizione Azure, quindi e' opportuno configurare budget e avvisi nel portale.
- Creazione account e autenticazione: https://learn.microsoft.com/azure/azure-maps/how-to-manage-account-keys
- Prezzi e quota gratuita: https://azure.microsoft.com/pricing/details/azure-maps/
## Aprire in Visual Studio ## Aprire in Visual Studio
@@ -50,16 +80,16 @@ Per Android:
dotnet build -f net9.0-android -p:AndroidSdkDirectory=C:\Users\livio\AppData\Local\Android\Sdk dotnet build -f net9.0-android -p:AndroidSdkDirectory=C:\Users\livio\AppData\Local\Android\Sdk
``` ```
## APK generato ## APK Release generato
La build Debug Android genera: La pubblicazione Android genera:
```text ```text
C:\Users\livio\Documents\LMEMappeCatastali\LMEMappeCatastaliApp\bin\Debug\net9.0-android\com.companyname.lmemappecatastaliapp-Signed.apk C:\Users\livio\Documents\LMEMappeCatastali\LMEMappeCatastaliApp\bin\Release\net9.0-android\publish\com.companyname.lmemappecatastaliapp-Signed.apk
``` ```
Per installarlo con telefono collegato e debug USB attivo: Per installarlo con telefono collegato e debug USB attivo:
```powershell ```powershell
C:\Users\livio\AppData\Local\Android\Sdk\platform-tools\adb.exe install -r C:\Users\livio\Documents\LMEMappeCatastali\LMEMappeCatastaliApp\bin\Debug\net9.0-android\com.companyname.lmemappecatastaliapp-Signed.apk C:\Users\livio\AppData\Local\Android\Sdk\platform-tools\adb.exe install -r C:\Users\livio\Documents\LMEMappeCatastali\LMEMappeCatastaliApp\bin\Release\net9.0-android\publish\com.companyname.lmemappecatastaliapp-Signed.apk
``` ```
File diff suppressed because it is too large. Load diff
+53
View File
@@ -0,0 +1,53 @@
using System.Text.Json;
namespace LMEMappeCatastaliApp.Services;
public sealed class AzureMapsService
{
private const string TilesetEndpoint = "https://atlas.microsoft.com/map/tileset";
private readonly HttpClient _httpClient = new()
{
Timeout = TimeSpan.FromSeconds(20)
};
private string? _validatedKey;
public AzureMapsService()
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.5");
_httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
}
public async Task ValidateKeyAsync(string subscriptionKey, CancellationToken cancellationToken = default)
{
var key = subscriptionKey.Trim();
if (string.IsNullOrWhiteSpace(key))
{
throw new ArgumentException("Chiave Azure Maps mancante.", nameof(subscriptionKey));
}
if (string.Equals(_validatedKey, key, StringComparison.Ordinal))
{
return;
}
var url = $"{TilesetEndpoint}?api-version=2024-04-01&tilesetId=microsoft.base.road";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("subscription-key", key);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(response.StatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden
? "Chiave Azure Maps non valida o priva dei permessi Render."
: $"Azure Maps ha risposto {((int)response.StatusCode)}.");
}
await using var content = await response.Content.ReadAsStreamAsync(cancellationToken);
using var payload = await JsonDocument.ParseAsync(content, cancellationToken: cancellationToken);
if (!payload.RootElement.TryGetProperty("tiles", out _))
{
throw new InvalidDataException("Azure Maps ha restituito una configurazione non valida.");
}
_validatedKey = key;
}
}
+342
View File
@@ -0,0 +1,342 @@
using System.Text;
using System.Text.Json;
using Parquet;
using Parquet.Data;
using Parquet.Schema;
namespace LMEMappeCatastaliApp.Services;
public sealed class CadastralCatalogService
{
private const string DataBaseUrl = "https://raw.githubusercontent.com/ondata/dati_catastali/main/S_0000_ITALIA/anagrafica/";
private readonly HttpClient _httpClient = new()
{
Timeout = Timeout.InfiniteTimeSpan
};
private readonly SemaphoreSlim _downloadLock = new(1, 1);
private IReadOnlyList<MunicipalityOption>? _municipalities;
public CadastralCatalogService()
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2");
}
public async Task<IReadOnlyList<RegionOption>> GetRegionsAsync()
{
var municipalities = await GetAllMunicipalitiesAsync();
return municipalities
.GroupBy(item => item.DataFile, StringComparer.OrdinalIgnoreCase)
.Select(group => new RegionOption(RegionName(group.Key), group.Key))
.OrderBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
}
public async Task<IReadOnlyList<MunicipalityOption>> GetMunicipalitiesAsync(string dataFile)
{
var municipalities = await GetAllMunicipalitiesAsync();
return municipalities
.Where(item => string.Equals(item.DataFile, dataFile, StringComparison.OrdinalIgnoreCase))
.OrderBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
}
public async Task<IReadOnlyList<string>> GetSheetsAsync(
MunicipalityOption municipality,
IProgress<CatalogProgress>? progress = null,
CancellationToken cancellationToken = default)
{
var path = await EnsureRegionFileAsync(municipality.DataFile, progress, cancellationToken);
progress?.Report(new CatalogProgress("Leggo i fogli del Comune...", null));
var sheets = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using var reader = await ParquetReader.CreateAsync(path, cancellationToken: cancellationToken);
var communeField = RequiredField(reader.Schema, "comune");
var sheetField = RequiredField(reader.Schema, "foglio");
for (var groupIndex = 0; groupIndex < reader.RowGroupCount; groupIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
using var group = reader.OpenRowGroupReader(groupIndex);
if (!MayContain(group.GetStatistics(communeField), municipality.Code))
{
continue;
}
var count = checked((int)group.RowCount);
var communes = new string[count];
var sheetValues = new string[count];
await group.ReadAsync(communeField, communes, cancellationToken: cancellationToken);
await group.ReadAsync(sheetField, sheetValues, cancellationToken: cancellationToken);
for (var index = 0; index < count; index++)
{
if (string.Equals(communes[index], municipality.Code, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(sheetValues[index]))
{
sheets.Add(sheetValues[index]);
}
}
}
progress?.Report(new CatalogProgress($"Trovati {sheets.Count} fogli.", 1));
return sheets.OrderBy(value => value, NaturalStringComparer.Instance).ToArray();
}
public async Task<IReadOnlyList<ParcelOption>> GetParcelsAsync(
MunicipalityOption municipality,
string sheet,
IProgress<CatalogProgress>? progress = null,
CancellationToken cancellationToken = default)
{
var path = await EnsureRegionFileAsync(municipality.DataFile, progress, cancellationToken);
progress?.Report(new CatalogProgress("Leggo le particelle del foglio...", null));
var parcels = new Dictionary<string, ParcelOption>(StringComparer.OrdinalIgnoreCase);
await using var reader = await ParquetReader.CreateAsync(path, cancellationToken: cancellationToken);
var communeField = RequiredField(reader.Schema, "comune");
var sheetField = RequiredField(reader.Schema, "foglio");
var parcelField = RequiredField(reader.Schema, "particella");
var longitudeField = RequiredField(reader.Schema, "x");
var latitudeField = RequiredField(reader.Schema, "y");
var inspireIdField = RequiredField(reader.Schema, "INSPIREID_LOCALID");
for (var groupIndex = 0; groupIndex < reader.RowGroupCount; groupIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
using var group = reader.OpenRowGroupReader(groupIndex);
if (!MayContain(group.GetStatistics(communeField), municipality.Code)
|| !MayContain(group.GetStatistics(sheetField), sheet))
{
continue;
}
var count = checked((int)group.RowCount);
var communes = new string[count];
var sheets = new string[count];
await group.ReadAsync(communeField, communes, cancellationToken: cancellationToken);
await group.ReadAsync(sheetField, sheets, cancellationToken: cancellationToken);
var matchingIndexes = Enumerable.Range(0, count)
.Where(index => string.Equals(communes[index], municipality.Code, StringComparison.OrdinalIgnoreCase)
&& string.Equals(sheets[index], sheet, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (matchingIndexes.Length == 0)
{
continue;
}
var parcelValues = new string[count];
var inspireIds = new string[count];
var longitudes = new long?[count];
var latitudes = new long?[count];
await group.ReadAsync(parcelField, parcelValues, cancellationToken: cancellationToken);
await group.ReadAsync(inspireIdField, inspireIds, cancellationToken: cancellationToken);
await group.ReadAsync<long>(longitudeField, longitudes.AsMemory(), cancellationToken: cancellationToken);
await group.ReadAsync<long>(latitudeField, latitudes.AsMemory(), cancellationToken: cancellationToken);
foreach (var index in matchingIndexes)
{
var number = parcelValues[index];
if (string.IsNullOrWhiteSpace(number)
|| parcels.ContainsKey(number)
|| !latitudes[index].HasValue
|| !longitudes[index].HasValue)
{
continue;
}
parcels[number] = new ParcelOption(
number,
latitudes[index]!.Value / 1_000_000d,
longitudes[index]!.Value / 1_000_000d,
inspireIds[index] ?? string.Empty);
}
}
progress?.Report(new CatalogProgress($"Trovate {parcels.Count} particelle.", 1));
return parcels.Values.OrderBy(item => item.Number, NaturalStringComparer.Instance).ToArray();
}
private async Task<IReadOnlyList<MunicipalityOption>> GetAllMunicipalitiesAsync()
{
if (_municipalities is not null)
{
return _municipalities;
}
await using var stream = await FileSystem.Current.OpenAppPackageFileAsync("Data/municipalities.json");
_municipalities = await JsonSerializer.DeserializeAsync<List<MunicipalityOption>>(
stream,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? [];
return _municipalities;
}
private async Task<string> EnsureRegionFileAsync(
string dataFile,
IProgress<CatalogProgress>? progress,
CancellationToken cancellationToken)
{
var safeFileName = Path.GetFileName(dataFile);
var destination = Path.Combine(FileSystem.Current.CacheDirectory, $"catasto-{safeFileName}");
if (IsValidParquetFile(destination))
{
progress?.Report(new CatalogProgress("Uso il catalogo regionale salvato sul telefono.", 1));
return destination;
}
await _downloadLock.WaitAsync(cancellationToken);
try
{
if (IsValidParquetFile(destination))
{
return destination;
}
var temporary = destination + ".download";
if (File.Exists(temporary))
{
File.Delete(temporary);
}
using var response = await _httpClient.GetAsync(
DataBaseUrl + Uri.EscapeDataString(safeFileName),
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
var total = response.Content.Headers.ContentLength;
await using var source = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var target = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 128 * 1024, true);
var buffer = new byte[128 * 1024];
long downloaded = 0;
int read;
while ((read = await source.ReadAsync(buffer, cancellationToken)) > 0)
{
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
downloaded += read;
double? fraction = total > 0 ? (double)downloaded / total.Value : null;
var megabytes = downloaded / 1_048_576d;
progress?.Report(new CatalogProgress($"Scarico dati regionali: {megabytes:0.0} MB", fraction));
}
await target.FlushAsync(cancellationToken);
target.Close();
File.Move(temporary, destination, true);
return destination;
}
catch
{
var temporary = destination + ".download";
if (File.Exists(temporary))
{
File.Delete(temporary);
}
throw;
}
finally
{
_downloadLock.Release();
}
}
private static bool IsValidParquetFile(string path)
{
if (!File.Exists(path) || new FileInfo(path).Length < 12)
{
return false;
}
using var stream = File.OpenRead(path);
Span<byte> marker = stackalloc byte[4];
if (stream.Read(marker) != marker.Length || Encoding.ASCII.GetString(marker) != "PAR1")
{
return false;
}
stream.Seek(-4, SeekOrigin.End);
return stream.Read(marker) == marker.Length && Encoding.ASCII.GetString(marker) == "PAR1";
}
private static DataField RequiredField(ParquetSchema schema, string name) =>
schema.FindDataField(name) ?? throw new InvalidDataException($"Colonna catastale mancante: {name}");
private static bool MayContain(DataColumnStatistics? statistics, string wanted)
{
if (statistics is null)
{
return true;
}
var minimum = StatisticText(statistics.MinValue);
var maximum = StatisticText(statistics.MaxValue);
return minimum is null || maximum is null
|| (string.Compare(wanted, minimum, StringComparison.OrdinalIgnoreCase) >= 0
&& string.Compare(wanted, maximum, StringComparison.OrdinalIgnoreCase) <= 0);
}
private static string? StatisticText(object? value) => value switch
{
null => null,
string text => text,
byte[] bytes => Encoding.UTF8.GetString(bytes),
ReadOnlyMemory<byte> memory => Encoding.UTF8.GetString(memory.Span),
_ => value.ToString()
};
private static string RegionName(string dataFile)
{
var key = Path.GetFileNameWithoutExtension(dataFile);
return key switch
{
"01_Piemonte" => "Piemonte",
"02_ValledAosta" => "Valle d'Aosta",
"03_Lombardia" => "Lombardia",
"05_Veneto" => "Veneto",
"06_Friuli-VeneziaGiulia" => "Friuli-Venezia Giulia",
"07_Liguria" => "Liguria",
"08_Emilia-Romagna" => "Emilia-Romagna",
"09_Toscana" => "Toscana",
"10_Umbria" => "Umbria",
"11_Marche" => "Marche",
"12_Lazio" => "Lazio",
"13_Abruzzo" => "Abruzzo",
"14_Molise" => "Molise",
"15_Campania" => "Campania",
"16_Puglia" => "Puglia",
"17_Basilicata" => "Basilicata",
"18_Calabria" => "Calabria",
"19_Sicilia" => "Sicilia",
"20_Sardegna" => "Sardegna",
_ => key.Length > 3 ? key[3..] : key
};
}
}
public sealed record RegionOption(string Name, string DataFile);
public sealed record MunicipalityOption(string Code, string Name, string DataFile);
public sealed record ParcelOption(string Number, double Latitude, double Longitude, string InspireId);
public sealed record CatalogProgress(string Message, double? Fraction);
internal sealed class NaturalStringComparer : IComparer<string>
{
public static NaturalStringComparer Instance { get; } = new();
public int Compare(string? left, string? right)
{
if (ReferenceEquals(left, right)) return 0;
if (left is null) return -1;
if (right is null) return 1;
var leftNumber = long.TryParse(left, out var leftValue);
var rightNumber = long.TryParse(right, out var rightValue);
if (leftNumber && rightNumber)
{
var numeric = leftValue.CompareTo(rightValue);
return numeric != 0 ? numeric : string.Compare(left, right, StringComparison.OrdinalIgnoreCase);
}
return string.Compare(left, right, StringComparison.CurrentCultureIgnoreCase);
}
}
+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);
}
}
}
+579 -145
View File
@@ -1,14 +1,32 @@
:root {
--ink: #17212b;
--muted: #63717c;
--surface: #ffffff;
--panel: #f4f6f7;
--line: #d6dde2;
--accent: #14705e;
--accent-strong: #0c5749;
--warning: #a34b08;
--nav-height: 64px;
}
html, html,
body, body,
#app, #app,
.app-shell { .app-shell,
.map-app,
.map-stage {
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
}
html,
body {
font-family: Inter, "Segoe UI", Arial, sans-serif; font-family: Inter, "Segoe UI", Arial, sans-serif;
background: #101820; background: #d8e0e4;
color: #17212b; color: var(--ink);
} }
button, button,
@@ -18,28 +36,32 @@ select {
} }
button { button {
min-height: 38px; min-height: 42px;
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
padding: 0 12px; padding: 0 14px;
background: #176b87; background: var(--accent);
color: #fff; color: #fff;
font-weight: 700; font-weight: 700;
} }
button:active { button:active:not(:disabled) {
transform: translateY(1px); transform: translateY(1px);
} }
button:disabled {
opacity: 0.48;
}
input, input,
select { select {
width: 100%; width: 100%;
min-height: 38px; min-height: 44px;
border: 1px solid #c8d3dc; border: 1px solid #bfcbd2;
border-radius: 6px; border-radius: 6px;
padding: 0 10px; padding: 0 11px;
background: #fff; background: #fff;
color: #17212b; color: var(--ink);
} }
input[type="range"], input[type="range"],
@@ -47,35 +69,257 @@ input[type="checkbox"] {
min-height: auto; min-height: auto;
} }
.map-app { .map-stage {
display: grid; position: relative;
grid-template-columns: 390px minmax(0, 1fr);
width: 100%;
height: 100%;
} }
.tool-panel { #map {
z-index: 800; position: absolute;
inset: 0;
background: #d8e0e4;
}
.map-toolbar {
position: absolute;
top: 12px;
left: 12px;
right: 12px;
z-index: 900;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 52px;
padding: 7px 8px 7px 13px;
border: 1px solid rgba(255, 255, 255, 0.54);
border-radius: 8px;
background: rgba(22, 31, 38, 0.9);
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.22);
color: #fff;
}
.app-identity {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.app-identity strong {
font-size: 18px;
white-space: nowrap;
}
.app-identity span {
overflow: hidden;
color: #bfe6dc;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar-button {
min-height: 38px;
background: #fff;
color: var(--ink);
}
.toolbar-actions,
.layer-button {
display: flex;
align-items: center;
gap: 7px;
}
.toolbar-actions {
flex: 0 0 auto;
}
.layer-button.is-active {
background: #ccebe3;
color: var(--accent-strong);
}
.layer-symbol {
width: 20px;
height: 20px;
background: url("../lib/leaflet/images/layers.png") center / contain no-repeat;
}
.map-search {
position: absolute;
top: 72px;
left: 12px;
right: 12px;
z-index: 890;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
max-width: 640px;
padding: 8px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.22);
opacity: 0;
pointer-events: none;
transform: translateY(-8px);
transition: opacity 140ms ease, transform 140ms ease;
}
.map-search.is-open {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.map-layer-panel {
position: absolute;
top: 72px;
right: 12px;
z-index: 940;
display: grid;
width: min(320px, calc(100% - 24px));
max-height: calc(100% - 152px);
overflow-y: auto;
border: 1px solid var(--line);
border-radius: 8px;
padding: 9px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.22);
}
.map-layer-panel > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 0 7px 4px;
border-bottom: 1px solid var(--line);
}
.layer-panel-close {
width: 36px;
min-width: 36px;
min-height: 34px;
padding: 0;
background: #e9eef0;
color: var(--ink);
font-size: 23px;
}
.map-layer-option {
display: flex;
align-items: center;
gap: 9px;
min-height: 45px;
padding: 6px 4px;
border-bottom: 1px solid #e5eaed;
}
.map-layer-option input {
flex: 0 0 auto;
width: 19px;
height: 19px;
}
.map-layer-option span {
display: grid;
gap: 2px;
}
.map-layer-option small {
color: var(--muted);
font-size: 10px;
font-weight: 600;
}
.layer-panel-actions {
display: grid;
gap: 7px;
padding-top: 9px;
}
.gps-overlay {
position: absolute;
top: 72px;
right: auto;
bottom: auto;
left: 12px;
z-index: 850;
display: grid;
grid-template-columns: minmax(160px, auto) auto auto;
align-items: center;
gap: 7px;
max-width: calc(100% - 24px);
padding: 7px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.2);
transition: top 140ms ease;
}
.gps-overlay.search-open {
top: 140px;
}
.gps-state {
display: grid;
min-width: 0;
padding: 0 6px;
font-size: 11px;
}
.gps-state span,
.gps-state strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gps-state strong {
color: #a33227;
}
.gps-main,
.gps-center {
min-height: 38px;
padding: 0 10px;
white-space: nowrap;
}
.gps-center {
background: #e7ecef;
color: var(--ink);
}
.tool-sheet {
position: absolute;
top: 0;
bottom: var(--nav-height);
left: 0;
z-index: 1100;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; width: min(430px, calc(100% - 48px));
overflow-y: auto; border-right: 1px solid var(--line);
padding: 16px; background: var(--panel);
background: #f6f8f9; box-shadow: 7px 0 24px rgba(0, 0, 0, 0.24);
border-right: 1px solid #d8e0e6;
} }
.brand { .sheet-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 14px;
padding: 18px 16px 13px;
border-bottom: 1px solid var(--line);
background: #fff;
} }
.eyebrow { .eyebrow {
margin: 0 0 3px; margin: 0 0 4px;
color: #667785; color: var(--muted);
font-size: 12px; font-size: 11px;
font-weight: 800; font-weight: 800;
text-transform: uppercase; text-transform: uppercase;
} }
@@ -88,74 +332,75 @@ p {
h1 { h1 {
margin-bottom: 0; margin-bottom: 0;
font-size: 25px; font-size: 22px;
line-height: 1.08; line-height: 1.12;
} }
h2 { h2 {
margin-bottom: 10px; margin: 10px 0 2px;
font-size: 15px; font-size: 15px;
} }
.status { .close-button {
border-radius: 999px; width: 42px;
padding: 5px 9px; min-width: 42px;
background: #dff5e5; padding: 0;
color: #166534; background: #e9eef0;
font-size: 12px; color: var(--ink);
font-weight: 800; font-size: 27px;
line-height: 1;
} }
.panel-section { .panel-content {
padding: 12px;
border: 1px solid #dbe4ea;
border-radius: 8px;
background: #fff;
}
.input-row,
.button-row {
display: flex;
gap: 8px;
}
.input-row input {
min-width: 0;
}
.field-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; gap: 12px;
gap: 10px; overflow-y: auto;
} padding: 15px 16px 24px;
.field-grid.compact {
grid-template-columns: 1fr;
} }
label { label {
display: grid; display: grid;
gap: 5px; gap: 5px;
color: #475866; color: #45545f;
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
} }
.hint { .button-row {
margin: 10px 0 0; display: flex;
color: #667785; gap: 8px;
font-size: 12px; }
line-height: 1.35;
.button-row > * {
flex: 1;
}
.primary-command,
.secondary-command {
width: 100%;
}
.secondary-command {
border: 1px solid #b9c7ce;
background: #fff;
color: var(--ink);
}
.danger-command {
width: 100%;
border: 1px solid #cfaca8;
background: #fff;
color: #9b2f26;
} }
.file-picker { .file-picker {
position: relative; position: relative;
min-height: 42px; min-height: 52px;
place-items: center; place-items: center;
border: 1px dashed #93a7b5; border: 1px dashed #7e969f;
border-radius: 8px; border-radius: 8px;
background: #f2f7fa; background: #fff;
color: #176b87; color: var(--accent-strong);
cursor: pointer; cursor: pointer;
} }
@@ -166,120 +411,309 @@ label {
cursor: pointer; cursor: pointer;
} }
.catalog-progress,
.service-note {
display: grid;
gap: 7px;
padding: 11px;
border: 1px solid #c7dce0;
border-radius: 8px;
background: #edf7f5;
color: #24443e;
font-size: 12px;
line-height: 1.4;
}
.catalog-progress progress {
width: 100%;
accent-color: var(--accent);
}
.service-note p {
margin-bottom: 0;
}
.protected-service {
border-color: #ead4b9;
background: #fff7ea;
color: #5d4024;
}
.layer-toggles { .layer-toggles {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 8px; gap: 9px;
margin-top: 10px; padding: 4px 0;
} }
.layer-toggles label, .layer-toggles label,
.toggle-line { .toggle-line {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 8px;
} }
.gps-readout { .layer-toggles input,
display: grid; .toggle-line input {
gap: 6px; width: 18px;
margin-bottom: 10px; height: 18px;
padding: 10px;
border-radius: 8px;
background: #eef6ff;
color: #21405d;
font-size: 13px;
}
.gps-readout strong {
color: #b91c1c;
}
.map-stage {
position: relative;
min-width: 0;
height: 100%;
}
#map {
width: 100%;
height: 100%;
background: #d4dde3;
} }
.map-info { .map-info {
position: absolute; position: absolute;
left: 14px; left: 12px;
right: 14px; bottom: 76px;
bottom: 14px; z-index: 820;
z-index: 700; max-width: min(620px, calc(100% - 460px));
max-width: 760px;
border-radius: 8px; border-radius: 8px;
padding: 10px 12px; padding: 9px 11px;
background: rgba(16, 24, 32, 0.86); background: rgba(22, 31, 38, 0.88);
color: #fff; color: #fff;
font-size: 13px; font-size: 12px;
pointer-events: none; pointer-events: none;
} }
footer { .wms-preview[hidden] {
color: #667785; display: none;
}
.wms-preview {
position: absolute;
inset: 0;
z-index: 1400;
display: grid;
place-items: center;
padding: 16px;
background: rgba(15, 22, 27, 0.72);
}
.wms-preview-dialog {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: min(980px, 100%);
height: min(820px, 100%);
overflow: hidden;
border-radius: 8px;
background: #fff;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.34);
}
.wms-preview-dialog > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 12px;
border-bottom: 1px solid var(--line);
}
.wms-preview-dialog > header div {
display: grid;
gap: 2px;
}
.wms-preview-dialog > header small {
color: var(--muted);
font-size: 10px;
}
.wms-preview-body {
display: grid;
min-height: 0;
place-items: center;
overflow: auto;
padding: 12px;
background: #eef2f3;
color: var(--muted);
}
.wms-preview-map {
position: relative;
flex: 0 0 auto;
overflow: hidden;
background: #fff;
}
.wms-preview-map[hidden] {
display: none;
}
.wms-preview-map img,
.wms-preview-map svg {
display: block;
width: 100%;
height: 100%;
}
.wms-preview-map svg {
position: absolute;
inset: 0;
pointer-events: none;
}
.wms-preview-parcel {
fill: rgba(251, 191, 36, 0.28);
fill-rule: evenodd;
stroke: #ef4444;
stroke-width: 4px;
vector-effect: non-scaling-stroke;
}
.wms-preview-gps-parcel {
fill: rgba(56, 189, 248, 0.24);
fill-rule: evenodd;
stroke: #1d4ed8;
stroke-width: 4px;
vector-effect: non-scaling-stroke;
}
.bottom-tabs {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 1200;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
height: var(--nav-height);
padding-bottom: env(safe-area-inset-bottom);
border-top: 1px solid #bcc7cd;
background: rgba(255, 255, 255, 0.98);
}
.bottom-tabs button {
min-width: 0;
min-height: 0;
border-radius: 0;
padding: 0 5px;
background: transparent;
color: #52616c;
font-size: 12px; font-size: 12px;
line-height: 1.35; }
.bottom-tabs button.active {
box-shadow: inset 0 3px 0 var(--accent);
color: var(--accent-strong);
}
footer {
margin-top: 4px;
color: var(--muted);
font-size: 11px;
line-height: 1.4;
}
.leaflet-bottom {
bottom: calc(var(--nav-height) + 6px);
} }
#blazor-error-ui { #blazor-error-ui {
background: #fff3cd;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.7rem 1.25rem;
position: fixed; position: fixed;
width: 100%; right: 8px;
z-index: 1000; bottom: calc(var(--nav-height) + 8px);
left: 8px;
z-index: 2000;
display: none;
border: 1px solid #e4be77;
border-radius: 8px;
padding: 10px 42px 10px 12px;
background: #fff3cd;
color: #5f4614;
} }
#blazor-error-ui .dismiss { #blazor-error-ui .dismiss {
cursor: pointer;
position: absolute; position: absolute;
right: 0.75rem; top: 7px;
top: 0.5rem; right: 10px;
cursor: pointer;
} }
.status-bar-safe-area { .status-bar-safe-area {
display: none; display: none;
} }
@media (max-width: 850px) { @media (max-width: 700px) {
.map-app { .map-toolbar {
grid-template-columns: 1fr; top: 8px;
grid-template-rows: 45% 55%; left: 8px;
right: 8px;
} }
.tool-panel { .map-search {
border-right: 0; top: 68px;
border-bottom: 1px solid #d8e0e6; left: 8px;
padding: 12px; right: 8px;
} }
.brand { .map-layer-panel {
align-items: center; top: 68px;
right: 8px;
width: min(320px, calc(100% - 16px));
max-height: calc(100% - 144px);
} }
h1 { .tool-sheet {
font-size: 22px; top: 10%;
} bottom: var(--nav-height);
}
@supports (-webkit-touch-callout: none) {
.status-bar-safe-area {
display: flex;
position: sticky;
top: 0;
height: env(safe-area-inset-top);
background-color: #f6f8f9;
width: 100%; width: 100%;
z-index: 1; border-top: 1px solid var(--line);
border-right: 0;
border-radius: 8px 8px 0 0;
box-shadow: 0 -7px 24px rgba(0, 0, 0, 0.24);
}
.gps-overlay {
top: 68px;
right: 8px;
bottom: auto;
left: 8px;
grid-template-columns: minmax(0, 1fr) auto auto;
max-width: none;
}
.gps-overlay.search-open {
top: 136px;
}
.gps-main,
.gps-center {
padding: 0 8px;
font-size: 11px;
}
.map-info {
right: 8px;
bottom: 72px;
left: 8px;
max-width: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bottom-tabs button {
font-size: 11px;
}
.wms-preview {
padding: 8px;
}
}
@media (max-width: 380px) {
.app-identity span {
display: none;
}
.gps-state {
min-width: 0;
}
.gps-center {
display: none;
}
.bottom-tabs button {
font-size: 10px;
} }
} }
+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>
+575 -241
View File
@@ -2,38 +2,49 @@ const lmeState = {
map: null, map: null,
baseLayers: {}, baseLayers: {},
currentBase: null, currentBase: null,
currentBaseName: "satellite",
cadastre: null, cadastre: null,
cadastreLayers: ["CP.CadastralParcel", "codice_plla", "CP.CadastralZoning", "fabbricati"], cadastreLayers: ["CP.CadastralParcel", "codice_plla", "CP.CadastralZoning", "fabbricati"],
cadastreOpacity: 0.75, cadastreOpacity: 0.75,
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, selectedParcelsLayer: null,
gpsTrailEnabled: true, gpsParcelLayer: null,
gpsTrailEnabled: false,
gpsTrailPoints: [], gpsTrailPoints: [],
gpsWatchId: null, gpsWatchId: null,
lastPosition: null, lastPosition: null,
lastWfsLookupAt: 0, fileImportBound: false,
wfsLookupInFlight: false, resizeBound: false,
duckDb: null, googleMapsLoading: null,
duckDbConnection: null azureMapsKey: null,
azureAttribution: null
}; };
window.lmeMap = { window.lmeMap = {
init, init,
search, showSearchResult,
locateParcelArea, displayOfficialParcel,
exportCadastreView, exportCadastreView,
closeCadastrePreview,
clearSelectedParcels,
clearMapElements,
fitImported, fitImported,
clearImported, clearImported,
addProjectMarker, addProjectMarker,
enableDrawTools, setDrawToolsVisible,
clearDrawings,
saveWorkspace, saveWorkspace,
loadWorkspace, loadWorkspace,
setBaseLayer, setBaseLayer,
configureGoogleMaps,
configureAzureMaps,
clearAzureMaps,
setCadastreOpacity, setCadastreOpacity,
toggleCadastreLayer, toggleCadastreLayer,
startGps, startGps,
@@ -41,7 +52,9 @@ window.lmeMap = {
centerOnGps, centerOnGps,
toggleTrail, toggleTrail,
setNativeGpsPosition, setNativeGpsPosition,
setGpsError setGpsError,
resize,
showInfo: setInfo
}; };
function init() { function init() {
@@ -56,236 +69,322 @@ function init() {
L.control.zoom({ position: "bottomright" }).addTo(lmeState.map); L.control.zoom({ position: "bottomright" }).addTo(lmeState.map);
lmeState.baseLayers.osm = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { lmeState.baseLayers.osm = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxNativeZoom: 19,
maxZoom: 22, maxZoom: 22,
attribution: "&copy; OpenStreetMap contributors" attribution: "&copy; OpenStreetMap contributors"
}); });
lmeState.baseLayers.satellite = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", { lmeState.baseLayers.satellite = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", {
maxNativeZoom: 19,
maxZoom: 22, maxZoom: 22,
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);
lmeState.currentParcelLayer = L.featureGroup().addTo(lmeState.map); lmeState.selectedParcelsLayer = L.featureGroup().addTo(lmeState.map);
lmeState.gpsParcelLayer = L.featureGroup().addTo(lmeState.map);
lmeState.gpsTrail = L.polyline([], { color: "#f97316", weight: 4, opacity: 0.85 }).addTo(lmeState.map); lmeState.gpsTrail = L.polyline([], { color: "#f97316", weight: 4, opacity: 0.85 }).addTo(lmeState.map);
refreshCadastreLayer(); refreshCadastreLayer();
bindFileImport(); bindFileImport();
if (!lmeState.resizeBound) {
window.addEventListener("resize", resize);
window.addEventListener("orientationchange", resize);
lmeState.resizeBound = true;
}
lmeState.map.on("click", async event => { lmeState.map.on("click", async event => {
const info = await queryFeatureInfo(event.latlng); const info = await queryFeatureInfo(event.latlng);
if (info) { if (info) {
L.popup().setLatLng(event.latlng).setContent(info).openOn(lmeState.map); L.popup().setLatLng(event.latlng).setContent(info).openOn(lmeState.map);
} }
}); });
lmeState.map.on("moveend", () => void updateAzureAttribution());
document.getElementById("closeWmsPreview")?.addEventListener("click", closeCadastrePreview);
document.getElementById("wmsPreview")?.addEventListener("click", event => {
if (event.target?.id === "wmsPreview") {
closeCadastrePreview();
}
});
document.addEventListener("keydown", event => {
if (event.key === "Escape") {
closeCadastrePreview();
}
});
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)}`);
function displayOfficialParcel(xmlText, latitude, longitude, parcel, label, shouldFit) {
const lat = Number(latitude);
const lon = Number(longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
setInfo("Coordinate della particella non valide.");
return; 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 locateParcelArea(region, municipality, municipalityCode, sheet, parcel, label) {
if (!sheet || !parcel || (!municipality && !municipalityCode)) {
setInfo("Inserisci Comune o codice catastale, Foglio e Particella.");
return;
}
setInfo("Cerco la particella nell'indice catastale...");
try { try {
const match = await findParcelIndexPoint(region, municipality, municipalityCode, sheet, parcel); const geojson = gmlParcelsToGeoJson(xmlText);
if (!match) {
const query = [municipality, region, "Italia"].filter(Boolean).join(", ");
await search(query);
setInfo("Particella non trovata nell'indice. Ho aperto il Comune: usa tap/GPS per interrogare AdE.");
return;
}
const meters = 8;
const deltaLat = meters / 111320;
const deltaLon = meters / (111320 * Math.cos(match.lat * Math.PI / 180));
const geojson = await fetchOfficialParcelsByBbox(match.lat - deltaLat, match.lon - deltaLon, match.lat + deltaLat, match.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, [match.lon, match.lat])) || geojson.features?.[0]; })
: null;
const feature = featureByNumber
|| (geojson.features || []).find(item => geometryContainsPoint(item.geometry, [lon, lat]))
|| geojson.features?.[0];
if (!feature) { if (!feature) {
lmeState.map.setView([match.lat, match.lon], 20); if (shouldFit) {
setInfo("Punto interno trovato, ma il WFS AdE non ha restituito il poligono in quel momento."); lmeState.map.setView([lat, lon], 20);
}
setInfo("Posizione trovata, ma il servizio WFS dell'Agenzia non ha restituito il confine. Riprova tra poco.");
return; return;
} }
lmeState.currentParcelLayer.clearLayers(); const ref = feature.properties?.NATIONALCADASTRALREFERENCE || feature.properties?.INSPIREID_LOCALID || label;
const layer = L.geoJSON(feature, { const targetGroup = normalizedParcel ? lmeState.selectedParcelsLayer : lmeState.gpsParcelLayer;
style: { color: "#ef4444", weight: 4, opacity: 1, fillColor: "#fbbf24", fillOpacity: 0.26 } if (!normalizedParcel) {
}).addTo(lmeState.currentParcelLayer); targetGroup.clearLayers();
fitLayer(layer); }
const ref = feature.properties?.NATIONALCADASTRALREFERENCE || feature.properties?.INSPIREID_LOCALID || `${match.comune}_${match.foglio}.${match.particella}`; const existingLayer = normalizedParcel
? targetGroup.getLayers().find(item => item.lmeParcelReference === ref)
: null;
if (existingLayer) {
if (shouldFit) {
fitLayer(existingLayer);
}
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(`La particella ${ref} e' gia evidenziata sulla mappa.`);
} catch (error) { return;
setInfo(`Ricerca catastale non completata: ${error.message}`);
}
} }
async function findParcelIndexPoint(region, municipality, municipalityCode, sheet, parcel) { const layer = L.geoJSON(feature, {
const conn = await getDuckDbConnection(); style: normalizedParcel
const code = (municipalityCode || "").trim().toUpperCase(); ? { color: "#ef4444", weight: 4, opacity: 1, fillColor: "#fbbf24", fillOpacity: 0.26 }
const name = (municipality || "").trim().toUpperCase().replaceAll("'", "''"); : { color: "#1d4ed8", weight: 4, opacity: 1, fillColor: "#38bdf8", fillOpacity: 0.2 }
const normalizedSheet = normalizeSheet(sheet); }).addTo(targetGroup);
const normalizedParcel = normalizeParcel(parcel).replaceAll("'", "''"); layer.lmeParcelReference = ref;
layer.bindPopup(`<strong>${escapeHtml(ref)}</strong>`);
if (shouldFit) {
fitLayer(layer);
}
let indexWhere = ""; if (normalizedParcel) {
if (code) { setText("parcelState", `Particella selezionata: ${ref}`);
indexWhere = `comune = '${code.replaceAll("'", "''")}'`; const count = lmeState.selectedParcelsLayer.getLayers().length;
setInfo(`Aggiunta ${ref}. Particelle evidenziate: ${count}.`);
} else { } else {
indexWhere = `upper(DENOMINAZIONE_IT) = '${name}'`; setText("parcelState", `Dentro particella AdE: ${ref}`);
} }
} catch (error) {
const indexUrl = "https://raw.githubusercontent.com/ondata/dati_catastali/main/S_0000_ITALIA/anagrafica/index.parquet"; setInfo(`Geometria catastale non leggibile: ${error.message}`);
const indexRows = await queryDuckDb(conn, `
SELECT comune, file, DENOMINAZIONE_IT
FROM '${indexUrl}'
WHERE ${indexWhere}
LIMIT 5
`);
if (!indexRows.length) {
return null;
} }
const row = chooseMunicipalityRow(indexRows, region);
const dataUrl = `https://raw.githubusercontent.com/ondata/dati_catastali/main/S_0000_ITALIA/anagrafica/${row.file}`;
const parcelRows = await queryDuckDb(conn, `
SELECT INSPIREID_LOCALID, comune, foglio, particella, x, y
FROM '${dataUrl}'
WHERE comune = '${row.comune}'
AND foglio = '${normalizedSheet}'
AND upper(particella) = upper('${normalizedParcel}')
LIMIT 1
`);
if (!parcelRows.length) {
return null;
}
const parcelRow = parcelRows[0];
return {
inspireId: parcelRow.INSPIREID_LOCALID,
comune: parcelRow.comune,
foglio: parcelRow.foglio,
particella: parcelRow.particella,
lon: Number(parcelRow.x) / 1000000,
lat: Number(parcelRow.y) / 1000000
};
}
function chooseMunicipalityRow(rows, region) {
const wanted = (region || "").trim().toUpperCase();
if (!wanted) {
return rows[0];
}
return rows.find(row => String(row.file || "").toUpperCase().includes(wanted)) || rows[0];
}
async function getDuckDbConnection() {
if (lmeState.duckDbConnection) {
return lmeState.duckDbConnection;
}
const duckdb = await import("https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm/+esm");
const bundles = duckdb.getJsDelivrBundles();
const bundle = await duckdb.selectBundle(bundles);
const workerUrl = URL.createObjectURL(new Blob([`importScripts("${bundle.mainWorker}");`], { type: "text/javascript" }));
const worker = new Worker(workerUrl);
const logger = new duckdb.ConsoleLogger();
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
URL.revokeObjectURL(workerUrl);
const conn = await db.connect();
try {
await conn.query("INSTALL httpfs; LOAD httpfs;");
} catch {
// Some DuckDB-WASM bundles already include HTTP support.
}
lmeState.duckDb = db;
lmeState.duckDbConnection = conn;
return conn;
}
async function queryDuckDb(conn, sql) {
const table = await conn.query(sql);
return table.toArray().map(row => {
if (typeof row.toJSON === "function") {
return row.toJSON();
}
const output = {};
for (const key of Object.keys(row)) {
output[key] = row[key];
}
return output;
});
}
function normalizeSheet(sheet) {
const value = String(sheet || "").trim().toUpperCase();
return /^\d+$/.test(value) ? value.padStart(4, "0") : value;
} }
function normalizeParcel(parcel) { function normalizeParcel(parcel) {
return String(parcel || "").trim().toUpperCase(); return String(parcel || "").trim().toUpperCase();
} }
function exportCadastreView() { function resize() {
const center = lmeState.map.getCenter(); if (!lmeState.map) {
const zoom = lmeState.map.getZoom(); return;
const layers = lmeState.cadastreLayers.join(","); }
const url = `${cadastreUrl()}service=WMS&version=1.3.0&request=GetMap&layers=${encodeURIComponent(layers)}&styles=&format=image/png&transparent=true&width=1200&height=1200&crs=EPSG:4258&bbox=${center.lat - 0.01},${center.lng - 0.01},${center.lat + 0.01},${center.lng + 0.01}`; window.setTimeout(() => lmeState.map.invalidateSize({ pan: false }), 50);
window.open(url, "_blank");
setInfo(`Vista catastale aperta per download. Zoom corrente: ${zoom}.`);
} }
function exportCadastreView() {
const layers = activeCadastreLayers();
if (!layers.length) {
setInfo("Attiva almeno un layer catastale prima di aprire la vista WMS.");
return;
}
const bounds = lmeState.map.getBounds();
const size = lmeState.map.getSize();
const scale = Math.min(1, 1200 / size.x, 1200 / size.y);
const width = Math.max(256, Math.round(size.x * scale));
const height = Math.max(256, Math.round(size.y * scale));
const bbox = [bounds.getSouth(), bounds.getWest(), bounds.getNorth(), bounds.getEast()].join(",");
const params = new URLSearchParams({
service: "WMS",
version: "1.3.0",
request: "GetMap",
layers: layers.join(","),
styles: "",
format: "image/png",
transparent: "true",
width: String(width),
height: String(height),
crs: "EPSG:4258",
bbox
});
const preview = document.getElementById("wmsPreview");
const previewBody = preview?.querySelector(".wms-preview-body");
const previewMap = document.getElementById("wmsPreviewMap");
const image = document.getElementById("wmsPreviewImage");
const overlay = document.getElementById("wmsPreviewOverlay");
const loading = document.getElementById("wmsPreviewLoading");
if (!preview || !previewBody || !previewMap || !image || !overlay || !loading) {
setInfo("Vista WMS interna non disponibile.");
return;
}
preview.hidden = false;
const availableWidth = Math.max(1, previewBody.clientWidth - 24);
const availableHeight = Math.max(1, previewBody.clientHeight - 24);
const displayScale = Math.min(1, availableWidth / width, availableHeight / height);
previewMap.style.width = `${Math.max(1, Math.round(width * displayScale))}px`;
previewMap.style.height = `${Math.max(1, Math.round(height * displayScale))}px`;
previewMap.hidden = true;
renderPreviewParcelOverlay(overlay, bounds, width, height);
loading.textContent = "Caricamento...";
loading.hidden = false;
image.onload = () => {
loading.hidden = true;
previewMap.hidden = false;
const highlightedCount = lmeState.selectedParcelsLayer.getLayers().length
+ lmeState.gpsParcelLayer.getLayers().length;
setInfo(`Vista WMS caricata con ${layers.length} layer e ${highlightedCount} particelle evidenziate.`);
};
image.onerror = () => {
previewMap.hidden = true;
loading.textContent = "Il servizio WMS non ha restituito l'immagine. Riprova tra poco.";
setInfo("Vista WMS non disponibile.");
};
image.src = `${cadastreUrl()}${params}`;
}
function closeCadastrePreview() {
const preview = document.getElementById("wmsPreview");
const previewMap = document.getElementById("wmsPreviewMap");
const image = document.getElementById("wmsPreviewImage");
const overlay = document.getElementById("wmsPreviewOverlay");
if (preview) {
preview.hidden = true;
}
if (previewMap) {
previewMap.hidden = true;
}
if (image) {
image.onload = null;
image.onerror = null;
image.removeAttribute("src");
}
overlay?.replaceChildren();
}
function renderPreviewParcelOverlay(svg, bounds, width, height) {
svg.replaceChildren();
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
appendPreviewGeometry(svg, lmeState.selectedParcelsLayer.toGeoJSON(), "wms-preview-parcel", bounds, width, height);
appendPreviewGeometry(svg, lmeState.gpsParcelLayer.toGeoJSON(), "wms-preview-gps-parcel", bounds, width, height);
}
function appendPreviewGeometry(svg, featureCollection, className, bounds, width, height) {
const namespace = "http://www.w3.org/2000/svg";
for (const feature of featureCollection?.features || []) {
for (const polygon of geometryPolygons(feature.geometry)) {
const pathData = polygon
.map(ring => ring
.map((coordinate, index) => {
const point = previewPixel(coordinate, bounds, width, height);
return `${index ? "L" : "M"}${point.x.toFixed(2)} ${point.y.toFixed(2)}`;
})
.join(" ") + " Z")
.join(" ");
if (!pathData.trim()) {
continue;
}
const path = document.createElementNS(namespace, "path");
path.setAttribute("d", pathData);
path.setAttribute("class", className);
svg.appendChild(path);
}
}
}
function geometryPolygons(geometry) {
if (!geometry) {
return [];
}
if (geometry.type === "Polygon") {
return [geometry.coordinates || []];
}
if (geometry.type === "MultiPolygon") {
return geometry.coordinates || [];
}
if (geometry.type === "GeometryCollection") {
return (geometry.geometries || []).flatMap(geometryPolygons);
}
return [];
}
function previewPixel(coordinate, bounds, width, height) {
const longitude = Number(coordinate?.[0]);
const latitude = Number(coordinate?.[1]);
return {
x: ((longitude - bounds.getWest()) / (bounds.getEast() - bounds.getWest())) * width,
y: ((bounds.getNorth() - latitude) / (bounds.getNorth() - bounds.getSouth())) * height
};
}
function bindFileImport() { function bindFileImport() {
const input = document.getElementById("gisFileInput"); if (lmeState.fileImportBound) {
if (!input) { return;
}
document.addEventListener("change", async event => {
if (event.target?.id !== "gisFileInput") {
return; return;
} }
input.addEventListener("change", async event => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) { if (!file) {
return; return;
@@ -308,6 +407,7 @@ function bindFileImport() {
setInfo(`File non leggibile: ${error.message}`); setInfo(`File non leggibile: ${error.message}`);
} }
}); });
lmeState.fileImportBound = true;
} }
async function readGisFile(file) { async function readGisFile(file) {
@@ -375,13 +475,52 @@ 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() {
const count = lmeState.drawings.getLayers().length;
if (!count) {
setInfo("Non ci sono disegni o POI da eliminare.");
return;
}
if (!window.confirm(`Eliminare ${count} disegni e POI dalla mappa?`)) {
return;
}
lmeState.drawings.clearLayers();
updateGpsInsideState();
setInfo("Disegni e POI eliminati dalla mappa.");
}
function saveWorkspace() { function saveWorkspace() {
const data = { const data = {
drawings: lmeState.drawings.toGeoJSON(), drawings: lmeState.drawings.toGeoJSON(),
@@ -417,7 +556,188 @@ function setBaseLayer(name) {
lmeState.map.removeLayer(lmeState.currentBase); lmeState.map.removeLayer(lmeState.currentBase);
} }
lmeState.currentBase = selected.addTo(lmeState.map); lmeState.currentBase = selected.addTo(lmeState.map);
lmeState.currentBaseName = selected === lmeState.baseLayers[name] ? name : "satellite";
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",
"azure-road": "Strade Azure Maps",
"azure-satellite": "Satellite Azure Maps",
"google-satellite": "Satellite Google Maps",
"google-roadmap": "Strade Google Maps"
};
setInfo(`Sfondo attivo: ${labels[name] || labels.satellite}. Il catasto AdE resta sovrapposto.`);
if (lmeState.currentBaseName.startsWith("azure-")) {
void updateAzureAttribution();
} else {
clearAzureAttribution();
}
}
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 configureAzureMaps(subscriptionKey) {
const key = (subscriptionKey || "").trim();
if (!key) {
throw new Error("Chiave Azure Maps mancante.");
}
if (lmeState.azureMapsKey === key
&& lmeState.baseLayers["azure-road"]
&& lmeState.baseLayers["azure-satellite"]) {
return;
}
const activeAzureLayer = lmeState.currentBaseName.startsWith("azure-")
? lmeState.currentBaseName
: null;
if (activeAzureLayer && lmeState.currentBase) {
lmeState.map.removeLayer(lmeState.currentBase);
}
lmeState.azureMapsKey = key;
lmeState.baseLayers["azure-road"] = createAzureLayer(key, "microsoft.base.road", 22);
lmeState.baseLayers["azure-satellite"] = createAzureLayer(key, "microsoft.imagery", 19);
if (activeAzureLayer) {
lmeState.currentBase = lmeState.baseLayers[activeAzureLayer].addTo(lmeState.map);
lmeState.currentBase.bringToBack();
void updateAzureAttribution();
}
}
function clearAzureMaps() {
if (lmeState.currentBaseName.startsWith("azure-") && lmeState.currentBase) {
lmeState.map.removeLayer(lmeState.currentBase);
lmeState.currentBase = lmeState.baseLayers.satellite.addTo(lmeState.map);
lmeState.currentBase.bringToBack();
lmeState.currentBaseName = "satellite";
}
delete lmeState.baseLayers["azure-road"];
delete lmeState.baseLayers["azure-satellite"];
lmeState.azureMapsKey = null;
clearAzureAttribution();
}
function createAzureLayer(key, tilesetId, maxNativeZoom) {
const url = `https://atlas.microsoft.com/map/tile?api-version=2024-04-01&tilesetId=${tilesetId}&zoom={z}&x={x}&y={y}&tileSize=256&language=it-IT&view=Auto&subscription-key=${encodeURIComponent(key)}`;
const layer = L.tileLayer(url, {
minZoom: tilesetId === "microsoft.imagery" ? 1 : 0,
maxNativeZoom,
maxZoom: 22,
attribution: "Microsoft Azure Maps"
});
let errorShown = false;
layer.on("tileerror", () => {
if (!errorShown) {
errorShown = true;
setInfo("Azure Maps non ha restituito le tessere. Controlla quota e permessi Render della chiave.");
}
});
return layer;
}
async function updateAzureAttribution() {
if (!lmeState.azureMapsKey || !lmeState.currentBaseName.startsWith("azure-")) {
return;
}
const requestedLayer = lmeState.currentBaseName;
const tilesetId = requestedLayer === "azure-satellite" ? "microsoft.imagery" : "microsoft.base.road";
const bounds = lmeState.map.getBounds();
const zoom = Math.min(lmeState.map.getZoom(), tilesetId === "microsoft.imagery" ? 19 : 22);
const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()].join(",");
const url = `https://atlas.microsoft.com/map/attribution?api-version=2024-04-01&tilesetId=${tilesetId}&zoom=${zoom}&bounds=${bbox}`;
try {
const response = await fetch(url, {
headers: {
Accept: "application/json",
"subscription-key": lmeState.azureMapsKey
}
});
if (!response.ok) {
return;
}
const payload = await response.json();
if (lmeState.currentBaseName !== requestedLayer) {
return;
}
const attribution = [...new Set((payload.copyrights || []).map(htmlToText).filter(Boolean))].join(" | ");
setAzureAttribution(attribution);
} catch {
clearAzureAttribution();
}
}
function setAzureAttribution(text) {
clearAzureAttribution();
if (!text) {
return;
}
lmeState.azureAttribution = escapeHtml(text);
lmeState.map.attributionControl.addAttribution(lmeState.azureAttribution);
}
function clearAzureAttribution() {
if (lmeState.azureAttribution) {
lmeState.map.attributionControl.removeAttribution(lmeState.azureAttribution);
lmeState.azureAttribution = null;
}
}
function htmlToText(value) {
const element = document.createElement("div");
element.innerHTML = String(value || "");
return element.textContent?.trim() || "";
} }
function setCadastreOpacity(value) { function setCadastreOpacity(value) {
@@ -436,6 +756,19 @@ function toggleCadastreLayer(layer, enabled) {
lmeState.cadastreLayers = lmeState.cadastreLayers.filter(item => item !== layer); lmeState.cadastreLayers = lmeState.cadastreLayers.filter(item => item !== layer);
} }
refreshCadastreLayer(); refreshCadastreLayer();
const layerNames = {
"CP.CadastralParcel": "Confini particelle",
codice_plla: "Numeri particelle",
"CP.CadastralZoning": "Fogli catastali",
fabbricati: "Fabbricati",
strade: "Strade catastali",
acque: "Acque"
};
if (enabled && layer === "codice_plla" && lmeState.map.getZoom() < 17) {
setInfo("Numeri particelle attivati. Ingrandisci la mappa almeno fino a zoom 17 per renderli visibili.");
return;
}
setInfo(`${layerNames[layer] || layer}: ${enabled ? "attivo" : "nascosto"}.`);
} }
function refreshCadastreLayer() { function refreshCadastreLayer() {
@@ -443,12 +776,14 @@ function refreshCadastreLayer() {
lmeState.map.removeLayer(lmeState.cadastre); lmeState.map.removeLayer(lmeState.cadastre);
} }
if (!lmeState.cadastreLayers.length) { const layers = activeCadastreLayers();
lmeState.cadastre = null;
if (!layers.length) {
return; return;
} }
lmeState.cadastre = L.tileLayer.wms(cadastreUrl(), { lmeState.cadastre = L.tileLayer.wms(cadastreUrl(), {
layers: lmeState.cadastreLayers.join(","), layers: layers.join(","),
format: "image/png", format: "image/png",
transparent: true, transparent: true,
version: "1.3.0", version: "1.3.0",
@@ -459,6 +794,49 @@ function refreshCadastreLayer() {
}).addTo(lmeState.map); }).addTo(lmeState.map);
} }
function activeCadastreLayers() {
const drawOrder = [
"CP.CadastralZoning",
"strade",
"acque",
"fabbricati",
"CP.CadastralParcel",
"codice_plla"
];
return drawOrder.filter(layer => lmeState.cadastreLayers.includes(layer));
}
function clearSelectedParcels() {
const count = lmeState.selectedParcelsLayer.getLayers().length;
lmeState.selectedParcelsLayer.clearLayers();
updateGpsInsideState();
setInfo(count ? `${count} particelle selezionate rimosse.` : "Non ci sono particelle selezionate da rimuovere.");
}
function clearMapElements() {
const count = lmeState.selectedParcelsLayer.getLayers().length
+ lmeState.gpsParcelLayer.getLayers().length
+ lmeState.imported.getLayers().length
+ lmeState.drawings.getLayers().length
+ lmeState.gpsTrailPoints.length;
if (!count) {
setInfo("La mappa non contiene elementi da pulire.");
return;
}
if (!window.confirm("Rimuovere particelle evidenziate, file importati, disegni, POI e traccia GPS dalla mappa?")) {
return;
}
lmeState.selectedParcelsLayer.clearLayers();
lmeState.gpsParcelLayer.clearLayers();
lmeState.imported.clearLayers();
lmeState.drawings.clearLayers();
lmeState.gpsTrailPoints = [];
lmeState.gpsTrail.setLatLngs([]);
updateGpsInsideState();
setInfo("Elementi della mappa rimossi. Sfondo e layer catastali restano attivi.");
}
function startGps() { function startGps() {
if (!navigator.geolocation) { if (!navigator.geolocation) {
setInfo("Geolocalizzazione non disponibile sul dispositivo."); setInfo("Geolocalizzazione non disponibile sul dispositivo.");
@@ -544,7 +922,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) {
@@ -558,6 +935,17 @@ function updateGpsInsideState() {
} }
const point = [lmeState.lastPosition[1], lmeState.lastPosition[0]]; const point = [lmeState.lastPosition[1], lmeState.lastPosition[0]];
const officialMatch = findContainingPolygon(point, lmeState.selectedParcelsLayer.toGeoJSON())
|| findContainingPolygon(point, lmeState.gpsParcelLayer.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) {
@@ -566,49 +954,11 @@ function updateGpsInsideState() {
return; return;
} }
setText("parcelState", "Fuori dai poligoni caricati"); setText(
} "parcelState",
lmeState.selectedParcelsLayer.getLayers().length || lmeState.gpsParcelLayer.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}`);
} 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) {
@@ -707,18 +1057,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();
@@ -734,10 +1072,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
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,156 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 600 60"
height="60"
width="600"
id="svg4225"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="spritesheet.svg"
inkscape:export-filename="/home/fpuga/development/upstream/icarto.Leaflet.draw/src/images/spritesheet-2x.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<metadata
id="metadata4258">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs4256" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1056"
id="namedview4254"
showgrid="false"
inkscape:zoom="1.3101852"
inkscape:cx="237.56928"
inkscape:cy="7.2419621"
inkscape:window-x="1920"
inkscape:window-y="24"
inkscape:window-maximized="1"
inkscape:current-layer="svg4225" />
<g
id="enabled"
style="fill:#464646;fill-opacity:1">
<g
id="polyline"
style="fill:#464646;fill-opacity:1">
<path
d="m 18,36 0,6 6,0 0,-6 -6,0 z m 4,4 -2,0 0,-2 2,0 0,2 z"
id="path4229"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 36,18 0,6 6,0 0,-6 -6,0 z m 4,4 -2,0 0,-2 2,0 0,2 z"
id="path4231"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 23.142,39.145 -2.285,-2.29 16,-15.998 2.285,2.285 z"
id="path4233"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
</g>
<path
id="polygon"
d="M 100,24.565 97.904,39.395 83.07,42 76,28.773 86.463,18 Z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
id="rectangle"
d="m 140,20 20,0 0,20 -20,0 z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
id="circle"
d="m 221,30 c 0,6.078 -4.926,11 -11,11 -6.074,0 -11,-4.922 -11,-11 0,-6.074 4.926,-11 11,-11 6.074,0 11,4.926 11,11 z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
id="marker"
d="m 270,19 c -4.971,0 -9,4.029 -9,9 0,4.971 5.001,12 9,14 4.001,-2 9,-9.029 9,-14 0,-4.971 -4.029,-9 -9,-9 z m 0,12.5 c -2.484,0 -4.5,-2.014 -4.5,-4.5 0,-2.484 2.016,-4.5 4.5,-4.5 2.485,0 4.5,2.016 4.5,4.5 0,2.486 -2.015,4.5 -4.5,4.5 z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<g
id="edit"
style="fill:#464646;fill-opacity:1">
<path
d="m 337,30.156 0,0.407 0,5.604 c 0,1.658 -1.344,3 -3,3 l -10,0 c -1.655,0 -3,-1.342 -3,-3 l 0,-10 c 0,-1.657 1.345,-3 3,-3 l 6.345,0 3.19,-3.17 -9.535,0 c -3.313,0 -6,2.687 -6,6 l 0,10 c 0,3.313 2.687,6 6,6 l 10,0 c 3.314,0 6,-2.687 6,-6 l 0,-8.809 -3,2.968"
id="path4240"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 338.72,24.637 -8.892,8.892 -2.828,0 0,-2.829 8.89,-8.89 z"
id="path4242"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 338.697,17.826 4,0 0,4 -4,0 z"
transform="matrix(-0.70698336,-0.70723018,0.70723018,-0.70698336,567.55917,274.78273)"
id="path4244"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
</g>
<g
id="remove"
style="fill:#464646;fill-opacity:1">
<path
d="m 381,42 18,0 0,-18 -18,0 0,18 z m 14,-16 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z"
id="path4247"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 395,20 0,-4 -10,0 0,4 -6,0 0,2 22,0 0,-2 -6,0 z m -2,0 -6,0 0,-2 6,0 0,2 z"
id="path4249"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
</g>
</g>
<g
id="disabled"
transform="translate(120,0)"
style="fill:#bbbbbb">
<use
xlink:href="#edit"
id="edit-disabled"
x="0"
y="0"
width="100%"
height="100%" />
<use
xlink:href="#remove"
id="remove-disabled"
x="0"
y="0"
width="100%"
height="100%" />
</g>
<path
style="fill:none;stroke:#464646;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle-3"
d="m 581.65725,30 c 0,6.078 -4.926,11 -11,11 -6.074,0 -11,-4.922 -11,-11 0,-6.074 4.926,-11 11,-11 6.074,0 11,4.926 11,11 z"
inkscape:connector-curvature="0" />
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB