Compare commits

..
1 Commits
Author SHA1 Message Date
livio ff2d4bccb6 Add structured multi-project storage 2026-09-14 09:56:55 +02:00
8 changed files with 545 additions and 35 deletions

No files matched your search

+201 -11
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>
@@ -161,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>
} }
@@ -268,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;
@@ -284,6 +311,8 @@
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 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;
@@ -293,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);
@@ -354,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)
@@ -361,6 +393,32 @@
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.
} }
} }
@@ -371,6 +429,10 @@
{ {
LayerPanelOpen = false; LayerPanelOpen = false;
} }
if (tab == "projects")
{
await RefreshProjectsAsync();
}
await Task.Delay(30); await Task.Delay(30);
await JS.InvokeVoidAsync("lmeMap.resize"); await JS.InvokeVoidAsync("lmeMap.resize");
} }
@@ -665,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;
@@ -678,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)
{ {
+2 -2
View File
@@ -28,8 +28,8 @@
<ApplicationId>com.companyname.lmemappecatastaliapp</ApplicationId> <ApplicationId>com.companyname.lmemappecatastaliapp</ApplicationId>
<!-- Versions --> <!-- Versions -->
<ApplicationDisplayVersion>0.2.6</ApplicationDisplayVersion> <ApplicationDisplayVersion>0.2.7</ApplicationDisplayVersion>
<ApplicationVersion>10</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();
+2 -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.
+1 -1
View File
@@ -13,7 +13,7 @@ public sealed class AzureMapsService
public 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"); _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);
+13
View File
@@ -459,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;
+123 -20
View File
@@ -43,8 +43,10 @@ window.lmeMap = {
addProjectMarker, addProjectMarker,
setDrawToolsVisible, setDrawToolsVisible,
clearDrawings, clearDrawings,
saveWorkspace, createProjectSnapshot,
loadWorkspace, loadProjectSnapshot,
getLegacyProjectSnapshot,
clearLegacyProjectSnapshot,
setBaseLayer, setBaseLayer,
configureGoogleMaps, configureGoogleMaps,
configureAzureMaps, configureAzureMaps,
@@ -197,7 +199,7 @@ function displayOfficialParcel(xmlText, latitude, longitude, parcel, label, shou
} }
const existingLayer = normalizedParcel const existingLayer = normalizedParcel
? targetGroup.getLayers().find(item => item.lmeParcelReference === ref) ? findParcelLayerByReference(targetGroup, ref)
: null; : null;
if (existingLayer) { if (existingLayer) {
if (shouldFit) { if (shouldFit) {
@@ -235,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;
@@ -459,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}`);
} }
@@ -528,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) {