diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index 868b408..b5ce158 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -5,6 +5,7 @@ @inject OfficialCadastreService OfficialCadastreService @inject LocationSearchService LocationSearchService @inject AzureMapsService AzureMapsService +@inject ProjectStorageService ProjectStorageService @implements IAsyncDisposable LME Mappe Catastali @@ -161,19 +162,42 @@ -

Progetti e POI

+

Archivio progetti

+ + + + +
+ + +
+
@ProjectArchiveMessage
+ +

POI e disegni

+ -
- - -
} @@ -268,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; @@ -284,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; @@ -293,6 +322,7 @@ private IReadOnlyList Municipalities { get; set; } = []; private IReadOnlyList Sheets { get; set; } = []; private IReadOnlyList Parcels { get; set; } = []; + private IReadOnlyList Projects { get; set; } = []; private MunicipalityOption? SelectedMunicipality => Municipalities.FirstOrDefault(item => item.Code == SelectedMunicipalityCode); @@ -354,6 +384,8 @@ { CatalogMessage = $"Catalogo Comuni non disponibile: {error.Message}"; } + + await RefreshProjectsAsync(); } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -361,6 +393,32 @@ if (firstRender) { await JS.InvokeVoidAsync("lmeMap.init"); + await MigrateLegacyProjectAsync(); + } + } + + private async Task MigrateLegacyProjectAsync() + { + try + { + var legacySnapshot = await JS.InvokeAsync("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. } } @@ -371,6 +429,10 @@ { LayerPanelOpen = false; } + if (tab == "projects") + { + await RefreshProjectsAsync(); + } await Task.Delay(30); await JS.InvokeVoidAsync("lmeMap.resize"); } @@ -665,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; @@ -678,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("confirm", $"Il progetto '{existing.Name}' esiste gia. Sovrascriverlo?")) + { + return; + } + + ProjectBusy = true; + try + { + var snapshot = await JS.InvokeAsync("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("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) { diff --git a/LMEMappeCatastaliApp.csproj b/LMEMappeCatastaliApp.csproj index 13d8d72..ba9f1be 100644 --- a/LMEMappeCatastaliApp.csproj +++ b/LMEMappeCatastaliApp.csproj @@ -28,8 +28,8 @@ com.companyname.lmemappecatastaliapp - 0.2.6 - 10 + 0.2.7 + 11 None diff --git a/MauiProgram.cs b/MauiProgram.cs index a74b593..546559b 100644 --- a/MauiProgram.cs +++ b/MauiProgram.cs @@ -21,6 +21,7 @@ public static class MauiProgram builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); diff --git a/README.md b/README.md index deb30bf..052dc51 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Services/AzureMapsService.cs b/Services/AzureMapsService.cs index 173f74d..a4e7f21 100644 --- a/Services/AzureMapsService.cs +++ b/Services/AzureMapsService.cs @@ -13,7 +13,7 @@ public sealed class AzureMapsService public AzureMapsService() { - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.6"); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.7"); _httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json"); } diff --git a/Services/ProjectStorageService.cs b/Services/ProjectStorageService.cs new file mode 100644 index 0000000..0406d72 --- /dev/null +++ b/Services/ProjectStorageService.cs @@ -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> 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 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 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> ReadProjectFilesAsync() + { + Directory.CreateDirectory(ProjectsDirectory); + var projects = new List(); + foreach (var path in Directory.EnumerateFiles(ProjectsDirectory, $"*{ProjectExtension}")) + { + try + { + await using var stream = File.OpenRead(path); + var document = await JsonSerializer.DeserializeAsync(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); diff --git a/wwwroot/css/app.css b/wwwroot/css/app.css index 8381965..3b17da3 100644 --- a/wwwroot/css/app.css +++ b/wwwroot/css/app.css @@ -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; diff --git a/wwwroot/js/lme-map.js b/wwwroot/js/lme-map.js index 0ceac13..e882673 100644 --- a/wwwroot/js/lme-map.js +++ b/wwwroot/js/lme-map.js @@ -43,8 +43,10 @@ window.lmeMap = { addProjectMarker, setDrawToolsVisible, clearDrawings, - saveWorkspace, - loadWorkspace, + createProjectSnapshot, + loadProjectSnapshot, + getLegacyProjectSnapshot, + clearLegacyProjectSnapshot, setBaseLayer, configureGoogleMaps, configureAzureMaps, @@ -197,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) { @@ -235,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; @@ -459,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(`${escapeHtml(label)}
${center.lat.toFixed(6)}, ${center.lng.toFixed(6)}`).openPopup(); setInfo(`POI aggiunto: ${label}`); } @@ -528,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"); +} - 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.gpsTrail.setLatLngs(lmeState.gpsTrailPoints); - fitLayer(L.featureGroup([lmeState.drawings, lmeState.imported])); - updateGpsInsideState(); - setInfo("Progetto caricato."); +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"); + } + + lmeState.drawings.clearLayers(); + lmeState.imported.clearLayers(); + 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(`${escapeHtml(reference)}`); + } + }).addTo(lmeState.selectedParcelsLayer); + + lmeState.gpsTrailPoints = Array.isArray(data.trail) ? data.trail : []; + lmeState.gpsTrail.setLatLngs(lmeState.gpsTrailPoints); + + 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: ${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(`${escapeHtml(label)}`); + } } function setBaseLayer(name) {