Compare commits

...
4 Commits
8 changed files with 1115 additions and 73 deletions

No files matched your search

+304 -29
View File
@@ -5,6 +5,7 @@
@inject OfficialCadastreService OfficialCadastreService @inject OfficialCadastreService OfficialCadastreService
@inject LocationSearchService LocationSearchService @inject LocationSearchService LocationSearchService
@inject AzureMapsService AzureMapsService @inject AzureMapsService AzureMapsService
@inject ProjectStorageService ProjectStorageService
@implements IAsyncDisposable @implements IAsyncDisposable
<PageTitle>LME Mappe Catastali</PageTitle> <PageTitle>LME Mappe Catastali</PageTitle>
@@ -18,7 +19,16 @@
<strong>LME Catasto</strong> <strong>LME Catasto</strong>
<span>@Status</span> <span>@Status</span>
</div> </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> <button class="toolbar-button" @onclick='() => SelectTab(ActiveTab == "search" ? "map" : "search")'>Cerca</button>
</div>
</header> </header>
<div class="map-search @(ActiveTab == "search" ? "is-open" : null)"> <div class="map-search @(ActiveTab == "search" ? "is-open" : null)">
@@ -26,6 +36,33 @@
<button @onclick="SearchLocation">Vai</button> <button @onclick="SearchLocation">Vai</button>
</div> </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-overlay @(ActiveTab == "search" ? "search-open" : null)">
<div class="gps-state"> <div class="gps-state">
<span id="gpsState">GPS non attivo</span> <span id="gpsState">GPS non attivo</span>
@@ -125,19 +162,42 @@
<button class="secondary-command" @onclick="ClearImportedLayers">Pulisci</button> <button class="secondary-command" @onclick="ClearImportedLayers">Pulisci</button>
</div> </div>
<h2>Progetti e POI</h2> <h2>Archivio progetti</h2>
<label> <label>
Nome Nome progetto
<input @bind="ProjectName" @bind:event="oninput" placeholder="Nome progetto o POI" /> <input @bind="NewProjectName" @bind:event="oninput" placeholder="Es. Rilievo San Nicola" maxlength="80" />
</label>
<label class="toggle-line project-save-option">
<input type="checkbox" @bind="IncludeCadastralParcels" />
<span>Includi le particelle catastali rosse</span>
</label>
<button class="primary-command" @onclick="SaveProject" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(NewProjectName))">Salva progetto</button>
<label>
Progetto salvato
<select value="@SelectedProjectId" @onchange="SelectProject" disabled="@(Projects.Count == 0 || ProjectBusy)">
<option value="">Seleziona un progetto</option>
@foreach (var project in Projects)
{
<option value="@project.Id">@project.Name - @project.UpdatedAt.ToLocalTime().ToString("dd/MM/yyyy HH:mm")</option>
}
</select>
</label>
<div class="button-row">
<button @onclick="LoadProject" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId))">Carica</button>
<button class="danger-command" @onclick="DeleteProject" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId))">Elimina</button>
</div>
<div class="project-archive-state">@ProjectArchiveMessage</div>
<h2>POI e disegni</h2>
<label>
Nome POI
<input @bind="PoiName" @bind:event="oninput" placeholder="Nome del punto" />
</label> </label>
<button class="primary-command" @onclick="AddProjectMarker">Aggiungi POI al centro mappa</button> <button class="primary-command" @onclick="AddProjectMarker">Aggiungi POI al centro mappa</button>
<button class="secondary-command" aria-pressed="@DrawToolsVisible" @onclick="ToggleDrawTools"> <button class="secondary-command" aria-pressed="@DrawToolsVisible" @onclick="ToggleDrawTools">
@(DrawToolsVisible ? "Nascondi strumenti di disegno" : "Mostra strumenti di disegno") @(DrawToolsVisible ? "Nascondi strumenti di disegno" : "Mostra strumenti di disegno")
</button> </button>
<div class="button-row">
<button @onclick="SaveWorkspace">Salva</button>
<button @onclick="LoadWorkspace">Carica</button>
</div>
<button class="danger-command" @onclick="ClearDrawings">Elimina disegni e POI</button> <button class="danger-command" @onclick="ClearDrawings">Elimina disegni e POI</button>
</section> </section>
} }
@@ -178,16 +238,6 @@
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 class="layer-toggles">
@foreach (var layer in CadastralLayers)
{
<label>
<input type="checkbox" checked="@layer.Enabled" @onchange="(args) => ToggleCadastreLayer(layer.Key, args)" />
@layer.Name
</label>
}
</div>
<h2>GPS e percorso</h2> <h2>GPS e percorso</h2>
<label class="toggle-line"> <label class="toggle-line">
<input type="checkbox" checked="@TrailEnabled" @onchange="ToggleTrail" /> <input type="checkbox" checked="@TrailEnabled" @onchange="ToggleTrail" />
@@ -202,7 +252,7 @@
<button class="secondary-command" @onclick="OpenOfficialReservedArea">Apri Area riservata per download</button> <button class="secondary-command" @onclick="OpenOfficialReservedArea">Apri Area riservata per download</button>
</div> </div>
<button class="secondary-command" @onclick="ExportCadastralImage">Apri vista WMS corrente</button> <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> <footer>Dati catastali: Agenzia delle Entrate, licenza CC BY 4.0. App indipendente.</footer>
</section> </section>
} }
@@ -211,6 +261,25 @@
<div id="mapInfo" class="map-info">Pronto</div> <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>
</section>
</div>
<nav class="bottom-tabs" aria-label="Sezioni principali"> <nav class="bottom-tabs" aria-label="Sezioni principali">
<button class="@(ActiveTab == "map" || ActiveTab == "search" ? "active" : null)" @onclick='() => SelectTab("map")'>Mappa</button> <button class="@(ActiveTab == "map" || ActiveTab == "search" ? "active" : null)" @onclick='() => SelectTab("map")'>Mappa</button>
<button class="@(ActiveTab == "cadastre" ? "active" : null)" @onclick='() => SelectTab("cadastre")'>Catasto</button> <button class="@(ActiveTab == "cadastre" ? "active" : null)" @onclick='() => SelectTab("cadastre")'>Catasto</button>
@@ -223,7 +292,10 @@
@code { @code {
private string ActiveTab { get; set; } = "map"; private string ActiveTab { get; set; } = "map";
private string SearchText { get; set; } = string.Empty; private string SearchText { get; set; } = string.Empty;
private string ProjectName { get; set; } = string.Empty; private string NewProjectName { get; set; } = string.Empty;
private string PoiName { get; set; } = string.Empty;
private string SelectedProjectId { get; set; } = string.Empty;
private string ProjectArchiveMessage { get; set; } = "Nessun progetto salvato.";
private string Status { get; set; } = "online"; private string Status { get; set; } = "online";
private string SelectedBaseLayer { get; set; } = "satellite"; private string SelectedBaseLayer { get; set; } = "satellite";
private string GoogleMapsApiKey { get; set; } = string.Empty; private string GoogleMapsApiKey { get; set; } = string.Empty;
@@ -238,6 +310,9 @@
private bool GpsActive { get; set; } private bool GpsActive { get; set; }
private bool DrawToolsVisible { get; set; } private bool DrawToolsVisible { get; set; }
private bool TrailEnabled { get; set; } private bool TrailEnabled { get; set; }
private bool LayerPanelOpen { get; set; }
private bool IncludeCadastralParcels { get; set; } = true;
private bool ProjectBusy { get; set; }
private CancellationTokenSource? CatalogCancellation { get; set; } private CancellationTokenSource? CatalogCancellation { get; set; }
private CancellationTokenSource? GpsLookupCancellation { get; set; } private CancellationTokenSource? GpsLookupCancellation { get; set; }
private long LastGpsLookupAt; private long LastGpsLookupAt;
@@ -247,6 +322,7 @@
private IReadOnlyList<MunicipalityOption> Municipalities { get; set; } = []; private IReadOnlyList<MunicipalityOption> Municipalities { get; set; } = [];
private IReadOnlyList<string> Sheets { get; set; } = []; private IReadOnlyList<string> Sheets { get; set; } = [];
private IReadOnlyList<ParcelOption> Parcels { get; set; } = []; private IReadOnlyList<ParcelOption> Parcels { get; set; } = [];
private IReadOnlyList<MapProjectSummary> Projects { get; set; } = [];
private MunicipalityOption? SelectedMunicipality => private MunicipalityOption? SelectedMunicipality =>
Municipalities.FirstOrDefault(item => item.Code == SelectedMunicipalityCode); Municipalities.FirstOrDefault(item => item.Code == SelectedMunicipalityCode);
@@ -272,11 +348,11 @@
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)
]; ];
@@ -308,6 +384,8 @@
{ {
CatalogMessage = $"Catalogo Comuni non disponibile: {error.Message}"; CatalogMessage = $"Catalogo Comuni non disponibile: {error.Message}";
} }
await RefreshProjectsAsync();
} }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -315,12 +393,58 @@
if (firstRender) if (firstRender)
{ {
await JS.InvokeVoidAsync("lmeMap.init"); await JS.InvokeVoidAsync("lmeMap.init");
await MigrateLegacyProjectAsync();
}
}
private async Task MigrateLegacyProjectAsync()
{
try
{
var legacySnapshot = await JS.InvokeAsync<string?>("lmeMap.getLegacyProjectSnapshot");
if (string.IsNullOrWhiteSpace(legacySnapshot))
{
return;
}
var migrated = await ProjectStorageService.SaveAsync(
"Progetto precedente",
legacySnapshot,
false);
await JS.InvokeVoidAsync("lmeMap.clearLegacyProjectSnapshot");
await RefreshProjectsAsync();
SelectedProjectId = migrated.Id;
await InvokeAsync(StateHasChanged);
}
catch
{
// The legacy value remains available and migration can be retried at the next start.
} }
} }
private async Task SelectTab(string tab) private async Task SelectTab(string tab)
{ {
ActiveTab = tab; ActiveTab = tab;
if (tab != "map")
{
LayerPanelOpen = false;
}
if (tab == "projects")
{
await RefreshProjectsAsync();
}
await Task.Delay(30);
await JS.InvokeVoidAsync("lmeMap.resize");
}
private async Task ToggleLayerPanel()
{
LayerPanelOpen = !LayerPanelOpen;
if (LayerPanelOpen)
{
ActiveTab = "map";
}
await Task.Delay(30); await Task.Delay(30);
await JS.InvokeVoidAsync("lmeMap.resize"); await JS.InvokeVoidAsync("lmeMap.resize");
} }
@@ -603,7 +727,7 @@
private Task FitImportedLayers() => JS.InvokeVoidAsync("lmeMap.fitImported").AsTask(); private Task FitImportedLayers() => JS.InvokeVoidAsync("lmeMap.fitImported").AsTask();
private Task ClearImportedLayers() => JS.InvokeVoidAsync("lmeMap.clearImported").AsTask(); private Task ClearImportedLayers() => JS.InvokeVoidAsync("lmeMap.clearImported").AsTask();
private Task AddProjectMarker() => JS.InvokeVoidAsync("lmeMap.addProjectMarker", ProjectName).AsTask(); private Task AddProjectMarker() => JS.InvokeVoidAsync("lmeMap.addProjectMarker", PoiName).AsTask();
private async Task ToggleDrawTools() private async Task ToggleDrawTools()
{ {
DrawToolsVisible = !DrawToolsVisible; DrawToolsVisible = !DrawToolsVisible;
@@ -616,8 +740,136 @@
} }
private Task ClearDrawings() => JS.InvokeVoidAsync("lmeMap.clearDrawings").AsTask(); private Task ClearDrawings() => JS.InvokeVoidAsync("lmeMap.clearDrawings").AsTask();
private Task SaveWorkspace() => JS.InvokeVoidAsync("lmeMap.saveWorkspace").AsTask();
private Task LoadWorkspace() => JS.InvokeVoidAsync("lmeMap.loadWorkspace").AsTask(); private async Task RefreshProjectsAsync()
{
try
{
Projects = await ProjectStorageService.GetProjectsAsync();
if (Projects.All(project => project.Id != SelectedProjectId))
{
SelectedProjectId = string.Empty;
}
ProjectArchiveMessage = Projects.Count == 0
? "Nessun progetto salvato."
: $"{Projects.Count} {(Projects.Count == 1 ? "progetto salvato" : "progetti salvati")} nell'archivio interno.";
}
catch (Exception error)
{
ProjectArchiveMessage = $"Archivio non disponibile: {error.GetBaseException().Message}";
}
}
private Task SelectProject(ChangeEventArgs args)
{
SelectedProjectId = args.Value?.ToString() ?? string.Empty;
return Task.CompletedTask;
}
private async Task SaveProject()
{
var name = NewProjectName.Trim();
if (string.IsNullOrWhiteSpace(name))
{
await JS.InvokeVoidAsync("lmeMap.showInfo", "Inserisci il nome del progetto.");
return;
}
var existing = Projects.FirstOrDefault(project =>
string.Equals(project.Name, name, StringComparison.CurrentCultureIgnoreCase));
if (existing is not null
&& !await JS.InvokeAsync<bool>("confirm", $"Il progetto '{existing.Name}' esiste gia. Sovrascriverlo?"))
{
return;
}
ProjectBusy = true;
try
{
var snapshot = await JS.InvokeAsync<string>("lmeMap.createProjectSnapshot", IncludeCadastralParcels);
var saved = await ProjectStorageService.SaveAsync(
name,
snapshot,
IncludeCadastralParcels,
existing?.Id);
await RefreshProjectsAsync();
SelectedProjectId = saved.Id;
NewProjectName = saved.Name;
await JS.InvokeVoidAsync(
"lmeMap.showInfo",
IncludeCadastralParcels
? $"Progetto salvato: {saved.Name}, incluse le particelle rosse."
: $"Progetto salvato: {saved.Name}, senza particelle rosse.");
}
catch (Exception error)
{
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto non salvato: {error.GetBaseException().Message}");
}
finally
{
ProjectBusy = false;
}
}
private async Task LoadProject()
{
var project = Projects.FirstOrDefault(item => item.Id == SelectedProjectId);
if (project is null)
{
await JS.InvokeVoidAsync("lmeMap.showInfo", "Seleziona il progetto da caricare.");
return;
}
ProjectBusy = true;
try
{
var snapshot = await ProjectStorageService.LoadAsync(project.Id);
await JS.InvokeVoidAsync("lmeMap.loadProjectSnapshot", snapshot, project.Name);
NewProjectName = project.Name;
IncludeCadastralParcels = project.IncludesCadastralParcels;
ActiveTab = "map";
await Task.Delay(30);
await JS.InvokeVoidAsync("lmeMap.resize");
}
catch (Exception error)
{
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto non caricato: {error.GetBaseException().Message}");
}
finally
{
ProjectBusy = false;
}
}
private async Task DeleteProject()
{
var project = Projects.FirstOrDefault(item => item.Id == SelectedProjectId);
if (project is null)
{
return;
}
if (!await JS.InvokeAsync<bool>("confirm", $"Eliminare definitivamente il progetto '{project.Name}'?"))
{
return;
}
ProjectBusy = true;
try
{
await ProjectStorageService.DeleteAsync(project.Id);
SelectedProjectId = string.Empty;
await RefreshProjectsAsync();
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto eliminato: {project.Name}.");
}
catch (Exception error)
{
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto non eliminato: {error.GetBaseException().Message}");
}
finally
{
ProjectBusy = false;
}
}
private Task CenterOnGps() => JS.InvokeVoidAsync("lmeMap.centerOnGps").AsTask(); private Task CenterOnGps() => JS.InvokeVoidAsync("lmeMap.centerOnGps").AsTask();
private async Task ToggleTrail(ChangeEventArgs args) private async Task ToggleTrail(ChangeEventArgs args)
{ {
@@ -711,8 +963,25 @@
} }
} }
private Task ChangeCadastreOpacity(ChangeEventArgs args) => JS.InvokeVoidAsync("lmeMap.setCadastreOpacity", args.Value?.ToString()).AsTask(); private Task ChangeCadastreOpacity(ChangeEventArgs args) => JS.InvokeVoidAsync("lmeMap.setCadastreOpacity", args.Value?.ToString()).AsTask();
private Task ToggleCadastreLayer(string layer, ChangeEventArgs args) => JS.InvokeVoidAsync("lmeMap.toggleCadastreLayer", layer, (bool?)args.Value == true).AsTask();
private Task ExportCadastralImage() => JS.InvokeVoidAsync("lmeMap.exportCadastreView").AsTask(); private async Task ToggleCadastreLayer(CadastralLayer layer, ChangeEventArgs args)
{
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( private Task OpenOfficialGeoportal() => Microsoft.Maui.ApplicationModel.Launcher.Default.OpenAsync(
new Uri("https://geoportale.cartografia.agenziaentrate.gov.it/")); new Uri("https://geoportale.cartografia.agenziaentrate.gov.it/"));
@@ -720,7 +989,13 @@
private Task OpenOfficialReservedArea() => Microsoft.Maui.ApplicationModel.Launcher.Default.OpenAsync( private Task OpenOfficialReservedArea() => Microsoft.Maui.ApplicationModel.Launcher.Default.OpenAsync(
new Uri("https://www.agenziaentrate.gov.it/portale/area-riservata")); new Uri("https://www.agenziaentrate.gov.it/portale/area-riservata"));
private sealed record CadastralLayer(string Key, string Name, bool Enabled); 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() public ValueTask DisposeAsync()
{ {
+2 -2
View File
@@ -28,8 +28,8 @@
<ApplicationId>com.companyname.lmemappecatastaliapp</ApplicationId> <ApplicationId>com.companyname.lmemappecatastaliapp</ApplicationId>
<!-- Versions --> <!-- Versions -->
<ApplicationDisplayVersion>0.2.3</ApplicationDisplayVersion> <ApplicationDisplayVersion>0.2.7</ApplicationDisplayVersion>
<ApplicationVersion>7</ApplicationVersion> <ApplicationVersion>11</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>
+1
View File
@@ -21,6 +21,7 @@ public static class MauiProgram
builder.Services.AddSingleton<OfficialCadastreService>(); builder.Services.AddSingleton<OfficialCadastreService>();
builder.Services.AddSingleton<LocationSearchService>(); builder.Services.AddSingleton<LocationSearchService>();
builder.Services.AddSingleton<AzureMapsService>(); builder.Services.AddSingleton<AzureMapsService>();
builder.Services.AddSingleton<ProjectStorageService>();
#if DEBUG #if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools(); builder.Services.AddBlazorWebViewDeveloperTools();
+8 -1
View File
@@ -12,7 +12,8 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata pe
- 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. - Toolbar di disegno completa di icone, modifica, eliminazione singola e pulizia di tutti i disegni/POI.
- POI/progetti salvabili in locale con `localStorage`. - Archivio multiprogetto nella cartella privata dell'app, con nome, selezione da elenco, sovrascrittura ed eliminazione.
- Ogni progetto salva file GIS, POI, disegni, traccia e inquadratura; l'inclusione delle particelle catastali rosse e' opzionale.
- 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.
@@ -24,6 +25,12 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata pe
- Catalogo dei Comuni incluso nell'APK e cataloghi regionali OnData scaricati in cache solo quando servono. - Catalogo dei Comuni incluso nell'APK e cataloghi regionali OnData scaricati in cache solo quando servono.
- Ricerca catastale eseguita dal codice .NET nativo, senza DuckDB WebAssembly nella WebView Android. - Ricerca catastale eseguita dal codice .NET nativo, senza DuckDB WebAssembly nella WebView Android.
- La registrazione della traccia GPS e' disattivata all'avvio; la toolbar di disegno puo' essere mostrata o nascosta dal tab Progetti. - 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.
- Richieste WMS catastali in EPSG:4258 compatibili con il servizio AdE; i numeri particella sono visibili alle scale di dettaglio consentite dal servizio (circa zoom 17 o superiore).
- Collegamenti separati al Geoportale pubblico e all'Area riservata AdE per CAPTCHA, SPID/CIE e download ufficiali. - Collegamenti separati al Geoportale pubblico e all'Area riservata AdE per CAPTCHA, SPID/CIE e download ufficiali.
## Limite ufficiale AdE da considerare ## Limite ufficiale AdE da considerare
+1 -1
View File
@@ -13,7 +13,7 @@ public sealed class AzureMapsService
public AzureMapsService() public AzureMapsService()
{ {
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.3"); _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.7");
_httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json"); _httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
} }
+202
View File
@@ -0,0 +1,202 @@
using System.Text;
using System.Text.Json;
namespace LMEMappeCatastaliApp.Services;
public sealed class ProjectStorageService
{
private const string ProjectExtension = ".lmeproject.json";
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private string ProjectsDirectory => Path.Combine(FileSystem.AppDataDirectory, "Projects");
public async Task<IReadOnlyList<MapProjectSummary>> GetProjectsAsync()
{
await _gate.WaitAsync();
try
{
var files = await ReadProjectFilesAsync();
return files
.Select(item => item.Document.ToSummary())
.OrderByDescending(item => item.UpdatedAt)
.ThenBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
}
finally
{
_gate.Release();
}
}
public async Task<MapProjectSummary> SaveAsync(
string name,
string snapshotJson,
bool includesCadastralParcels,
string? projectId = null)
{
var normalizedName = NormalizeName(name);
using var snapshotDocument = JsonDocument.Parse(snapshotJson);
if (snapshotDocument.RootElement.ValueKind != JsonValueKind.Object)
{
throw new InvalidDataException("I dati della mappa non sono validi.");
}
await _gate.WaitAsync();
try
{
Directory.CreateDirectory(ProjectsDirectory);
var files = await ReadProjectFilesAsync();
var existing = files.FirstOrDefault(item =>
(!string.IsNullOrWhiteSpace(projectId) && item.Document.Id == projectId)
|| string.Equals(item.Document.Name, normalizedName, StringComparison.CurrentCultureIgnoreCase));
var now = DateTimeOffset.UtcNow;
var id = existing?.Document.Id ?? Guid.NewGuid().ToString("N");
var document = new MapProjectDocument
{
SchemaVersion = 1,
Id = id,
Name = normalizedName,
CreatedAt = existing?.Document.CreatedAt ?? now,
UpdatedAt = now,
IncludesCadastralParcels = includesCadastralParcels,
Map = snapshotDocument.RootElement.Clone()
};
var path = existing?.Path ?? Path.Combine(ProjectsDirectory, BuildFileName(normalizedName, id));
var temporaryPath = path + ".tmp";
try
{
await File.WriteAllTextAsync(
temporaryPath,
JsonSerializer.Serialize(document, _jsonOptions),
new UTF8Encoding(false));
File.Move(temporaryPath, path, true);
}
finally
{
if (File.Exists(temporaryPath))
{
File.Delete(temporaryPath);
}
}
return document.ToSummary();
}
finally
{
_gate.Release();
}
}
public async Task<string> LoadAsync(string projectId)
{
await _gate.WaitAsync();
try
{
var project = (await ReadProjectFilesAsync())
.FirstOrDefault(item => item.Document.Id == projectId)
?? throw new FileNotFoundException("Il progetto selezionato non esiste piu'.");
return project.Document.Map.GetRawText();
}
finally
{
_gate.Release();
}
}
public async Task DeleteAsync(string projectId)
{
await _gate.WaitAsync();
try
{
var project = (await ReadProjectFilesAsync())
.FirstOrDefault(item => item.Document.Id == projectId)
?? throw new FileNotFoundException("Il progetto selezionato non esiste piu'.");
File.Delete(project.Path);
}
finally
{
_gate.Release();
}
}
private async Task<IReadOnlyList<ProjectFileEntry>> ReadProjectFilesAsync()
{
Directory.CreateDirectory(ProjectsDirectory);
var projects = new List<ProjectFileEntry>();
foreach (var path in Directory.EnumerateFiles(ProjectsDirectory, $"*{ProjectExtension}"))
{
try
{
await using var stream = File.OpenRead(path);
var document = await JsonSerializer.DeserializeAsync<MapProjectDocument>(stream, _jsonOptions);
if (document is not null
&& !string.IsNullOrWhiteSpace(document.Id)
&& !string.IsNullOrWhiteSpace(document.Name)
&& document.Map.ValueKind == JsonValueKind.Object)
{
projects.Add(new ProjectFileEntry(path, document));
}
}
catch (JsonException)
{
// A damaged project is ignored so the remaining archive stays usable.
}
catch (IOException)
{
}
}
return projects;
}
private static string NormalizeName(string name)
{
var normalized = string.Join(' ', (name ?? string.Empty).Split(
[' ', '\t', '\r', '\n'],
StringSplitOptions.RemoveEmptyEntries));
if (string.IsNullOrWhiteSpace(normalized))
{
throw new ArgumentException("Inserisci il nome del progetto.");
}
return normalized.Length <= 80 ? normalized : normalized[..80];
}
private static string BuildFileName(string name, string id)
{
var invalid = Path.GetInvalidFileNameChars().ToHashSet();
var safeName = new string(name
.Select(character => invalid.Contains(character) ? '-' : character)
.ToArray())
.Trim(' ', '.');
safeName = string.IsNullOrWhiteSpace(safeName) ? "Progetto" : safeName;
return $"{safeName}--{id[..8]}{ProjectExtension}";
}
private sealed record ProjectFileEntry(string Path, MapProjectDocument Document);
private sealed class MapProjectDocument
{
public int SchemaVersion { get; init; }
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public bool IncludesCadastralParcels { get; init; }
public JsonElement Map { get; init; }
public MapProjectSummary ToSummary() => new(Id, Name, UpdatedAt, IncludesCadastralParcels);
}
}
public sealed record MapProjectSummary(
string Id,
string Name,
DateTimeOffset UpdatedAt,
bool IncludesCadastralParcels);
+207
View File
@@ -124,6 +124,28 @@ input[type="checkbox"] {
color: var(--ink); 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 { .map-search {
position: absolute; position: absolute;
top: 72px; top: 72px;
@@ -150,6 +172,73 @@ input[type="checkbox"] {
transform: translateY(0); 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 { .gps-overlay {
position: absolute; position: absolute;
top: 72px; top: 72px;
@@ -370,6 +459,19 @@ label {
height: 18px; height: 18px;
} }
.project-save-option {
padding: 10px 11px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
}
.project-archive-state {
color: var(--muted);
font-size: 11px;
line-height: 1.35;
}
.map-info { .map-info {
position: absolute; position: absolute;
left: 12px; left: 12px;
@@ -384,6 +486,100 @@ label {
pointer-events: none; pointer-events: none;
} }
.wms-preview[hidden] {
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 { .bottom-tabs {
position: absolute; position: absolute;
right: 0; right: 0;
@@ -462,6 +658,13 @@ footer {
right: 8px; right: 8px;
} }
.map-layer-panel {
top: 68px;
right: 8px;
width: min(320px, calc(100% - 16px));
max-height: calc(100% - 144px);
}
.tool-sheet { .tool-sheet {
top: 10%; top: 10%;
bottom: var(--nav-height); bottom: var(--nav-height);
@@ -504,6 +707,10 @@ footer {
.bottom-tabs button { .bottom-tabs button {
font-size: 11px; font-size: 11px;
} }
.wms-preview {
padding: 8px;
}
} }
@media (max-width: 380px) { @media (max-width: 380px) {
+390 -40
View File
@@ -13,7 +13,8 @@ const lmeState = {
gpsMarker: null, gpsMarker: null,
gpsAccuracy: null, gpsAccuracy: null,
gpsTrail: null, gpsTrail: null,
currentParcelLayer: null, selectedParcelsLayer: null,
gpsParcelLayer: null,
gpsTrailEnabled: false, gpsTrailEnabled: false,
gpsTrailPoints: [], gpsTrailPoints: [],
gpsWatchId: null, gpsWatchId: null,
@@ -25,18 +26,27 @@ const lmeState = {
azureAttribution: null azureAttribution: null
}; };
// AdE publishes the cadastral WMS in ETRS89. WMS 1.1.1 keeps the longitude,
// latitude BBOX order expected by Leaflet while sending the required CRS code.
const cadastreCrs = L.extend({}, L.CRS.EPSG4326, { code: "EPSG:4258" });
window.lmeMap = { window.lmeMap = {
init, init,
showSearchResult, showSearchResult,
displayOfficialParcel, displayOfficialParcel,
exportCadastreView, exportCadastreView,
closeCadastrePreview,
clearSelectedParcels,
clearMapElements,
fitImported, fitImported,
clearImported, clearImported,
addProjectMarker, addProjectMarker,
setDrawToolsVisible, setDrawToolsVisible,
clearDrawings, clearDrawings,
saveWorkspace, createProjectSnapshot,
loadWorkspace, loadProjectSnapshot,
getLegacyProjectSnapshot,
clearLegacyProjectSnapshot,
setBaseLayer, setBaseLayer,
configureGoogleMaps, configureGoogleMaps,
configureAzureMaps, configureAzureMaps,
@@ -104,7 +114,8 @@ function init() {
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();
@@ -124,6 +135,18 @@ function init() {
}); });
lmeState.map.on("moveend", () => void updateAzureAttribution()); 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");
} }
@@ -169,18 +192,39 @@ function displayOfficialParcel(xmlText, latitude, longitude, parcel, label, shou
return; return;
} }
lmeState.currentParcelLayer.clearLayers(); const ref = feature.properties?.NATIONALCADASTRALREFERENCE || feature.properties?.INSPIREID_LOCALID || label;
const targetGroup = normalizedParcel ? lmeState.selectedParcelsLayer : lmeState.gpsParcelLayer;
if (!normalizedParcel) {
targetGroup.clearLayers();
}
const existingLayer = normalizedParcel
? findParcelLayerByReference(targetGroup, ref)
: null;
if (existingLayer) {
if (shouldFit) {
fitLayer(existingLayer);
}
setText("parcelState", `Particella selezionata: ${ref}`);
setInfo(`La particella ${ref} e' gia evidenziata sulla mappa.`);
return;
}
const layer = L.geoJSON(feature, { const layer = L.geoJSON(feature, {
style: { color: "#ef4444", weight: 4, opacity: 1, fillColor: "#fbbf24", fillOpacity: 0.26 } style: normalizedParcel
}).addTo(lmeState.currentParcelLayer); ? { color: "#ef4444", weight: 4, opacity: 1, fillColor: "#fbbf24", fillOpacity: 0.26 }
: { color: "#1d4ed8", weight: 4, opacity: 1, fillColor: "#38bdf8", fillOpacity: 0.2 }
}).addTo(targetGroup);
layer.lmeParcelReference = ref;
layer.bindPopup(`<strong>${escapeHtml(ref)}</strong>`);
if (shouldFit) { if (shouldFit) {
fitLayer(layer); fitLayer(layer);
} }
const ref = feature.properties?.NATIONALCADASTRALREFERENCE || feature.properties?.INSPIREID_LOCALID || label;
if (normalizedParcel) { if (normalizedParcel) {
setText("parcelState", `Particella selezionata: ${ref}`); setText("parcelState", `Particella selezionata: ${ref}`);
setInfo(`Trovata ${ref}. Ora puoi attivare il GPS e camminare dentro il confine evidenziato.`); const count = lmeState.selectedParcelsLayer.getLayers().length;
setInfo(`Aggiunta ${ref}. Particelle evidenziate: ${count}.`);
} else { } else {
setText("parcelState", `Dentro particella AdE: ${ref}`); setText("parcelState", `Dentro particella AdE: ${ref}`);
} }
@@ -193,6 +237,23 @@ function normalizeParcel(parcel) {
return String(parcel || "").trim().toUpperCase(); return String(parcel || "").trim().toUpperCase();
} }
function findParcelLayerByReference(group, reference) {
let match = null;
group.eachLayer(layer => {
if (match) {
return;
}
if (layer.lmeParcelReference === reference) {
match = layer;
return;
}
if (typeof layer.eachLayer === "function") {
match = findParcelLayerByReference(layer, reference);
}
});
return match;
}
function resize() { function resize() {
if (!lmeState.map) { if (!lmeState.map) {
return; return;
@@ -201,12 +262,143 @@ function resize() {
} }
function exportCadastreView() { function exportCadastreView() {
const center = lmeState.map.getCenter(); const layers = activeCadastreLayers();
const zoom = lmeState.map.getZoom(); if (!layers.length) {
const layers = lmeState.cadastreLayers.join(","); setInfo("Attiva almeno un layer catastale prima di aprire la vista WMS.");
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}`; return;
window.open(url, "_blank"); }
setInfo(`Vista catastale aperta per download. Zoom corrente: ${zoom}.`);
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;
const zoomHint = layers.includes("codice_plla") && lmeState.map.getZoom() < 17
? " Ingrandisci almeno fino a zoom 17 per leggere i numeri delle particelle."
: "";
setInfo(`Vista WMS caricata con ${layers.length} layer e ${highlightedCount} particelle evidenziate.${zoomHint}`);
};
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() {
@@ -286,6 +478,10 @@ function addProjectMarker(name) {
const center = lmeState.map.getCenter(); const center = lmeState.map.getCenter();
const label = (name || "Nuovo POI").trim(); const label = (name || "Nuovo POI").trim();
const marker = L.marker(center, { draggable: true }).addTo(lmeState.drawings); const marker = L.marker(center, { draggable: true }).addTo(lmeState.drawings);
marker.feature = {
type: "Feature",
properties: { name: label }
};
marker.bindPopup(`<strong>${escapeHtml(label)}</strong><br>${center.lat.toFixed(6)}, ${center.lng.toFixed(6)}`).openPopup(); marker.bindPopup(`<strong>${escapeHtml(label)}</strong><br>${center.lat.toFixed(6)}, ${center.lng.toFixed(6)}`).openPopup();
setInfo(`POI aggiunto: ${label}`); setInfo(`POI aggiunto: ${label}`);
} }
@@ -355,33 +551,113 @@ function clearDrawings() {
setInfo("Disegni e POI eliminati dalla mappa."); setInfo("Disegni e POI eliminati dalla mappa.");
} }
function saveWorkspace() { function createProjectSnapshot(includeCadastralParcels) {
const data = { const center = lmeState.map.getCenter();
return JSON.stringify({
schemaVersion: 1,
view: {
latitude: center.lat,
longitude: center.lng,
zoom: lmeState.map.getZoom()
},
drawings: lmeState.drawings.toGeoJSON(), drawings: lmeState.drawings.toGeoJSON(),
imported: lmeState.imported.toGeoJSON(), imported: lmeState.imported.toGeoJSON(),
trail: lmeState.gpsTrailPoints selectedParcels: includeCadastralParcels
}; ? lmeState.selectedParcelsLayer.toGeoJSON()
localStorage.setItem("lmeMapWorkspace", JSON.stringify(data)); : { type: "FeatureCollection", features: [] },
setInfo("Progetti, POI, file e percorso salvati sul dispositivo."); trail: lmeState.gpsTrailPoints.map(point => [
Number(point.lat ?? point[0]),
Number(point.lng ?? point[1])
])
});
} }
function loadWorkspace() { function getLegacyProjectSnapshot() {
const raw = localStorage.getItem("lmeMapWorkspace"); return localStorage.getItem("lmeMapWorkspace");
if (!raw) { }
setInfo("Nessun progetto salvato trovato.");
return; function clearLegacyProjectSnapshot() {
localStorage.removeItem("lmeMapWorkspace");
}
function loadProjectSnapshot(raw, projectName) {
try {
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!data || typeof data !== "object") {
throw new Error("contenuto non valido");
} }
const data = JSON.parse(raw);
lmeState.drawings.clearLayers(); lmeState.drawings.clearLayers();
lmeState.imported.clearLayers(); lmeState.imported.clearLayers();
L.geoJSON(data.drawings, { style: { color: "#f97316", weight: 3, fillOpacity: 0.16 } }).addTo(lmeState.drawings); lmeState.selectedParcelsLayer.clearLayers();
L.geoJSON(data.imported, { style: { color: "#22c55e", weight: 3, fillOpacity: 0.18 } }).addTo(lmeState.imported); lmeState.gpsParcelLayer.clearLayers();
lmeState.gpsTrailPoints = data.trail || [];
L.geoJSON(data.drawings || emptyFeatureCollection(), {
style: { color: "#f97316", weight: 3, fillOpacity: 0.16 },
onEachFeature: bindStoredFeaturePopup
}).addTo(lmeState.drawings);
L.geoJSON(data.imported || emptyFeatureCollection(), {
style: { color: "#22c55e", weight: 3, opacity: 0.95, fillOpacity: 0.18 },
pointToLayer: (feature, latlng) => L.circleMarker(latlng, {
radius: 7,
color: "#22c55e",
fillColor: "#ffffff",
fillOpacity: 0.9
}),
onEachFeature: bindStoredFeaturePopup
}).addTo(lmeState.imported);
L.geoJSON(data.selectedParcels || emptyFeatureCollection(), {
style: { color: "#ef4444", weight: 4, opacity: 1, fillColor: "#fbbf24", fillOpacity: 0.26 },
onEachFeature: (feature, layer) => {
const properties = feature?.properties || {};
const reference = properties.NATIONALCADASTRALREFERENCE
|| properties.INSPIREID_LOCALID
|| properties.LABEL
|| "Particella catastale";
layer.lmeParcelReference = reference;
layer.bindPopup(`<strong>${escapeHtml(reference)}</strong>`);
}
}).addTo(lmeState.selectedParcelsLayer);
lmeState.gpsTrailPoints = Array.isArray(data.trail) ? data.trail : [];
lmeState.gpsTrail.setLatLngs(lmeState.gpsTrailPoints); lmeState.gpsTrail.setLatLngs(lmeState.gpsTrailPoints);
fitLayer(L.featureGroup([lmeState.drawings, lmeState.imported]));
const projectLayers = L.featureGroup([
lmeState.drawings,
lmeState.imported,
lmeState.selectedParcelsLayer,
lmeState.gpsTrail
]);
if (projectLayers.getBounds().isValid()) {
fitLayer(projectLayers);
} else if (Number.isFinite(data.view?.latitude)
&& Number.isFinite(data.view?.longitude)
&& Number.isFinite(data.view?.zoom)) {
lmeState.map.setView([data.view.latitude, data.view.longitude], data.view.zoom);
}
const parcelCount = lmeState.selectedParcelsLayer.toGeoJSON().features?.length || 0;
setText("parcelState", parcelCount
? `${parcelCount} particelle del progetto`
: "Nessuna particella selezionata");
updateGpsInsideState(); updateGpsInsideState();
setInfo("Progetto caricato."); setInfo(`Progetto caricato: ${projectName || "senza nome"}.`);
} catch (error) {
setInfo(`Progetto non leggibile: ${error.message}`);
throw error;
}
}
function emptyFeatureCollection() {
return { type: "FeatureCollection", features: [] };
}
function bindStoredFeaturePopup(feature, layer) {
const properties = feature?.properties || {};
const label = properties.name || properties.Name || properties.label || properties.LABEL;
if (label) {
layer.bindPopup(`<strong>${escapeHtml(label)}</strong>`);
}
} }
function setBaseLayer(name) { function setBaseLayer(name) {
@@ -590,6 +866,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() {
@@ -597,20 +886,80 @@ 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(), { const cadastreLayer = 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.1.1",
crs: L.CRS.EPSG4326, crs: cadastreCrs,
maxZoom: 22, maxZoom: 22,
opacity: lmeState.cadastreOpacity, opacity: lmeState.cadastreOpacity,
attribution: "Agenzia delle Entrate - CC BY 4.0" attribution: "Agenzia delle Entrate - CC BY 4.0"
}).addTo(lmeState.map); });
let errorShown = false;
cadastreLayer.on("tileerror", () => {
if (errorShown) {
return;
}
errorShown = true;
setInfo("Layer catastali non caricati dal servizio AdE. Controlla la connessione e riprova.");
});
cadastreLayer.on("load", () => {
errorShown = false;
});
lmeState.cadastre = cadastreLayer.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() {
@@ -711,7 +1060,8 @@ function updateGpsInsideState() {
} }
const point = [lmeState.lastPosition[1], lmeState.lastPosition[0]]; const point = [lmeState.lastPosition[1], lmeState.lastPosition[0]];
const officialMatch = findContainingPolygon(point, lmeState.currentParcelLayer.toGeoJSON()); const officialMatch = findContainingPolygon(point, lmeState.selectedParcelsLayer.toGeoJSON())
|| findContainingPolygon(point, lmeState.gpsParcelLayer.toGeoJSON());
if (officialMatch) { if (officialMatch) {
const reference = officialMatch.properties?.NATIONALCADASTRALREFERENCE const reference = officialMatch.properties?.NATIONALCADASTRALREFERENCE
|| officialMatch.properties?.INSPIREID_LOCALID || officialMatch.properties?.INSPIREID_LOCALID
@@ -731,7 +1081,7 @@ function updateGpsInsideState() {
setText( setText(
"parcelState", "parcelState",
lmeState.currentParcelLayer.getLayers().length lmeState.selectedParcelsLayer.getLayers().length || lmeState.gpsParcelLayer.getLayers().length
? "Fuori dalla particella catastale evidenziata" ? "Fuori dalla particella catastale evidenziata"
: "Fuori dai poligoni caricati"); : "Fuori dai poligoni caricati");
} }