Compare commits

...
3 Commits
Author SHA1 Message Date
livio ff2d4bccb6 Add structured multi-project storage 2026-09-14 09:56:55 +02:00
livio ab43c5ca30 Fix Agency cadastral WMS coordinate system 2026-09-11 20:57:15 +02:00
livio abc9516a0d Overlay selected parcels on WMS preview 2026-09-11 20:42:40 +02:00
8 changed files with 693 additions and 50 deletions

No files matched your search

+205 -12
View File
@@ -5,6 +5,7 @@
@inject OfficialCadastreService OfficialCadastreService
@inject LocationSearchService LocationSearchService
@inject AzureMapsService AzureMapsService
@inject ProjectStorageService ProjectStorageService
@implements IAsyncDisposable
<PageTitle>LME Mappe Catastali</PageTitle>
@@ -161,19 +162,42 @@
<button class="secondary-command" @onclick="ClearImportedLayers">Pulisci</button>
</div>
<h2>Progetti e POI</h2>
<h2>Archivio progetti</h2>
<label>
Nome
<input @bind="ProjectName" @bind:event="oninput" placeholder="Nome progetto o POI" />
Nome progetto
<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>
<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">
<button @onclick="SaveWorkspace">Salva</button>
<button @onclick="LoadWorkspace">Carica</button>
</div>
<button class="danger-command" @onclick="ClearDrawings">Elimina disegni e POI</button>
</section>
}
@@ -242,13 +266,16 @@
<header>
<div>
<strong id="wmsPreviewTitle">Vista WMS catastale</strong>
<small>Immagine ufficiale AdE dell'area visibile, senza mappa di sfondo</small>
<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>
@@ -265,7 +292,10 @@
@code {
private string ActiveTab { get; set; } = "map";
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 SelectedBaseLayer { get; set; } = "satellite";
private string GoogleMapsApiKey { get; set; } = string.Empty;
@@ -281,6 +311,8 @@
private bool DrawToolsVisible { 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? GpsLookupCancellation { get; set; }
private long LastGpsLookupAt;
@@ -290,6 +322,7 @@
private IReadOnlyList<MunicipalityOption> Municipalities { get; set; } = [];
private IReadOnlyList<string> Sheets { get; set; } = [];
private IReadOnlyList<ParcelOption> Parcels { get; set; } = [];
private IReadOnlyList<MapProjectSummary> Projects { get; set; } = [];
private MunicipalityOption? SelectedMunicipality =>
Municipalities.FirstOrDefault(item => item.Code == SelectedMunicipalityCode);
@@ -351,6 +384,8 @@
{
CatalogMessage = $"Catalogo Comuni non disponibile: {error.Message}";
}
await RefreshProjectsAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -358,6 +393,32 @@
if (firstRender)
{
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.
}
}
@@ -368,6 +429,10 @@
{
LayerPanelOpen = false;
}
if (tab == "projects")
{
await RefreshProjectsAsync();
}
await Task.Delay(30);
await JS.InvokeVoidAsync("lmeMap.resize");
}
@@ -662,7 +727,7 @@
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 Task AddProjectMarker() => JS.InvokeVoidAsync("lmeMap.addProjectMarker", PoiName).AsTask();
private async Task ToggleDrawTools()
{
DrawToolsVisible = !DrawToolsVisible;
@@ -675,8 +740,136 @@
}
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 async Task ToggleTrail(ChangeEventArgs args)
{
+2 -2
View File
@@ -28,8 +28,8 @@
<ApplicationId>com.companyname.lmemappecatastaliapp</ApplicationId>
<!-- Versions -->
<ApplicationDisplayVersion>0.2.4</ApplicationDisplayVersion>
<ApplicationVersion>8</ApplicationVersion>
<ApplicationDisplayVersion>0.2.7</ApplicationDisplayVersion>
<ApplicationVersion>11</ApplicationVersion>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
<WindowsPackageType>None</WindowsPackageType>
+1
View File
@@ -21,6 +21,7 @@ public static class MauiProgram
builder.Services.AddSingleton<OfficialCadastreService>();
builder.Services.AddSingleton<LocationSearchService>();
builder.Services.AddSingleton<AzureMapsService>();
builder.Services.AddSingleton<ProjectStorageService>();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
+4 -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.
- 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`.
- 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.
- Marker posizione, cerchio accuratezza in metri, tracciamento percorso.
- Controllo se la posizione GPS cade dentro un poligono importato o disegnato.
@@ -28,6 +29,8 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata pe
- 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.
## Limite ufficiale AdE da considerare
+1 -1
View File
@@ -13,7 +13,7 @@ public sealed class AzureMapsService
public AzureMapsService()
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.4");
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.7");
_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);
+50 -5
View File
@@ -459,6 +459,19 @@ label {
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 {
position: absolute;
left: 12px;
@@ -527,14 +540,46 @@ label {
color: var(--muted);
}
.wms-preview-body img {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
.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;
+228 -29
View File
@@ -26,6 +26,10 @@ const lmeState = {
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 = {
init,
showSearchResult,
@@ -39,8 +43,10 @@ window.lmeMap = {
addProjectMarker,
setDrawToolsVisible,
clearDrawings,
saveWorkspace,
loadWorkspace,
createProjectSnapshot,
loadProjectSnapshot,
getLegacyProjectSnapshot,
clearLegacyProjectSnapshot,
setBaseLayer,
configureGoogleMaps,
configureAzureMaps,
@@ -193,7 +199,7 @@ function displayOfficialParcel(xmlText, latitude, longitude, parcel, label, shou
}
const existingLayer = normalizedParcel
? targetGroup.getLayers().find(item => item.lmeParcelReference === ref)
? findParcelLayerByReference(targetGroup, ref)
: null;
if (existingLayer) {
if (shouldFit) {
@@ -231,6 +237,23 @@ function normalizeParcel(parcel) {
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() {
if (!lmeState.map) {
return;
@@ -265,40 +288,117 @@ function exportCadastreView() {
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 || !image || !loading) {
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.hidden = true;
image.onload = () => {
loading.hidden = true;
image.hidden = false;
setInfo(`Vista WMS caricata con ${layers.length} layer.`);
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.");
};
preview.hidden = false;
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() {
@@ -378,6 +478,10 @@ function addProjectMarker(name) {
const center = lmeState.map.getCenter();
const label = (name || "Nuovo POI").trim();
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();
setInfo(`POI aggiunto: ${label}`);
}
@@ -447,33 +551,113 @@ function clearDrawings() {
setInfo("Disegni e POI eliminati dalla mappa.");
}
function saveWorkspace() {
const data = {
function createProjectSnapshot(includeCadastralParcels) {
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(),
imported: lmeState.imported.toGeoJSON(),
trail: lmeState.gpsTrailPoints
};
localStorage.setItem("lmeMapWorkspace", JSON.stringify(data));
setInfo("Progetti, POI, file e percorso salvati sul dispositivo.");
selectedParcels: includeCadastralParcels
? lmeState.selectedParcelsLayer.toGeoJSON()
: { type: "FeatureCollection", features: [] },
trail: lmeState.gpsTrailPoints.map(point => [
Number(point.lat ?? point[0]),
Number(point.lng ?? point[1])
])
});
}
function loadWorkspace() {
const raw = localStorage.getItem("lmeMapWorkspace");
if (!raw) {
setInfo("Nessun progetto salvato trovato.");
return;
function getLegacyProjectSnapshot() {
return localStorage.getItem("lmeMapWorkspace");
}
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.imported.clearLayers();
L.geoJSON(data.drawings, { style: { color: "#f97316", weight: 3, fillOpacity: 0.16 } }).addTo(lmeState.drawings);
L.geoJSON(data.imported, { style: { color: "#22c55e", weight: 3, fillOpacity: 0.18 } }).addTo(lmeState.imported);
lmeState.gpsTrailPoints = data.trail || [];
lmeState.selectedParcelsLayer.clearLayers();
lmeState.gpsParcelLayer.clearLayers();
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);
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();
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) {
@@ -708,16 +892,31 @@ function refreshCadastreLayer() {
return;
}
lmeState.cadastre = L.tileLayer.wms(cadastreUrl(), {
const cadastreLayer = L.tileLayer.wms(cadastreUrl(), {
layers: layers.join(","),
format: "image/png",
transparent: true,
version: "1.3.0",
crs: L.CRS.EPSG4326,
version: "1.1.1",
crs: cadastreCrs,
maxZoom: 22,
opacity: lmeState.cadastreOpacity,
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() {