diff --git a/Components/MenuHelp.razor b/Components/MenuHelp.razor index 10ae50a..fecdbde 100644 --- a/Components/MenuHelp.razor +++ b/Components/MenuHelp.razor @@ -80,7 +80,7 @@ "Nuovo progetto: inserisci un nome, scegli se includere i contorni catastali evidenziati e premi Salva progetto. Vengono conservati oggetti, gruppi, nomi, attributi e opzioni di visualizzazione.", "Aggiornamento: dopo aver aperto un progetto, usa di nuovo Salva progetto con lo stesso nome per aggiornarlo. Se nelle Impostazioni attivi Aggiorna automaticamente il progetto aperto, le modifiche vengono registrate dopo 3 secondi di inattivita.", "Cartelle: seleziona un progetto, scrivi per esempio Cantieri 2026 e premi Sposta per organizzarlo nell'archivio; questa operazione non sposta le geometrie sulla mappa.", - "Duplica crea una copia modificabile, utile prima di una variante importante o quando ricevi un progetto in sola lettura.", + "Rinomina cambia il nome del progetto selezionato senza alterarne contenuti e revisioni. Duplica crea invece una copia modificabile, utile prima di una variante importante o quando ricevi un progetto in sola lettura.", "Archivia nasconde logicamente i lavori conclusi senza eliminarli; Ripristina li riporta tra quelli attivi. Elimina rimuove invece il progetto dall'archivio.", "Condividi modificabile consente al destinatario di continuare il lavoro; Condividi in sola lettura protegge l'originale e richiede una duplicazione per modificarlo.", "Revisioni: seleziona una data e usa Apri revisione per consultarne lo stato oppure Confronta per vedere le differenze con la versione corrente.", @@ -88,7 +88,8 @@ ], "tools" => [ - "Tabella oggetti: apre una vista ordinata di particelle, file importati e disegni. Usa i flag per selezionare piu righe e il lucchetto per proteggerle dalle modifiche.", + "Tabella oggetti: apre una vista ordinata di particelle, file importati e disegni. Usa i flag per selezionare piu righe, il lucchetto per proteggerle e la matita per modificare i metadati.", + "Copia metadati: nella maschera Metadati oggetto scegli Copia da e l'oggetto sorgente. Attiva Importa anche i valori per copiare nomi e contenuti; disattivalo per creare soltanto gli stessi campi, lasciandoli vuoti o mantenendo i valori gia presenti.", "Coordinate WGS84 e UTM: scegli un punto sulla mappa per leggere latitudine e longitudine oppure le coordinate metriche UTM, utili per rilievi e confronti tecnici.", "Registro operazioni: mostra in ordine cronologico disegni, modifiche, rinomine, spostamenti ed eliminazioni, cosi puoi capire cosa e cambiato durante la sessione.", "Cestino: Ripristina ultima rimozione recupera l'elemento eliminato piu recente. Svuota cestino elimina definitivamente tutti gli elementi rimossi e non consente ulteriori recuperi.", diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index 2951382..90dd7ba 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -354,6 +354,7 @@
+ @@ -1403,6 +1404,39 @@ await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto duplicato: {copy.Name}."); } + private async Task RenameProject() + { + if (SelectedProject is not { } project) return; + var requestedName = await JS.InvokeAsync("lmeMap.promptProjectName", project.Name); + if (string.IsNullOrWhiteSpace(requestedName) + || string.Equals(requestedName.Trim(), project.Name, StringComparison.CurrentCulture)) + { + return; + } + + ProjectBusy = true; + try + { + var renamed = await ProjectStorageService.RenameAsync(project.Id, requestedName); + await RefreshProjectsAsync(); + SelectedProjectId = renamed.Id; + NewProjectName = renamed.Name; + if (!HasUnsavedChanges) + { + ExportFileName = renamed.Name; + } + await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto rinominato: {renamed.Name}."); + } + catch (Exception error) + { + await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto non rinominato: {error.GetBaseException().Message}"); + } + finally + { + ProjectBusy = false; + } + } + private async Task UpdateProjectFolder() { if (SelectedProject is null) return; diff --git a/LMEMappeCatastaliApp.csproj b/LMEMappeCatastaliApp.csproj index 41bc10a..4ca4f85 100644 --- a/LMEMappeCatastaliApp.csproj +++ b/LMEMappeCatastaliApp.csproj @@ -28,8 +28,8 @@ com.companyname.lmemappecatastaliapp - 0.4.4 - 39 + 0.4.5 + 40 $(ApplicationDisplayVersion) diff --git a/README.md b/README.md index e8a85b3..085429c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata per consultare mappe catastali e lavorare sul campo con GPS. +## Versione 0.4.5 + +- Corretto il layout del pannello Layer sui telefoni e sui foldable: checkbox, lucchetto, ordinamento e freccia di espansione non si sovrappongono e restano allineati. +- La tabella oggetti include un comando dedicato per modificare i metadati di ciascun elemento. +- L'editor metadati permette di copiare i campi da un altro oggetto, scegliendo se importare anche i valori oppure soltanto i nomi. +- Il pannello Progetti consente di rinominare un progetto duplicato o esistente senza perdere contenuti, identita e revisioni. +- La barra Leaflet include una guida completa con istruzioni ed esempi concreti per disegno, modifica, eliminazione, riparazione, fori e confronto confini. + ## Versione 0.4.4 - I comandi `Rinomina`, `Sposta nel gruppo` e `Rimuovi` occupano una riga completa e restano visibili anche nei pannelli Layer molto stretti. diff --git a/Services/MapExportService.cs b/Services/MapExportService.cs index ac06055..76e1b08 100644 --- a/Services/MapExportService.cs +++ b/Services/MapExportService.cs @@ -85,7 +85,7 @@ public sealed class MapExportService { var root = new XElement("gpx", new XAttribute("version", "1.1"), - new XAttribute("creator", "LME Mappe Catastali 0.4.4"), + new XAttribute("creator", "LME Mappe Catastali 0.4.5"), new XAttribute(XNamespace.Xmlns + "xsi", "http://www.w3.org/2001/XMLSchema-instance"), new XElement("metadata", new XElement("name", name))); foreach (var feature in features.EnumerateArray()) diff --git a/Services/ProjectStorageService.cs b/Services/ProjectStorageService.cs index 5d59e1d..a8463aa 100644 --- a/Services/ProjectStorageService.cs +++ b/Services/ProjectStorageService.cs @@ -147,6 +147,36 @@ public sealed class ProjectStorageService finally { _gate.Release(); } } + public async Task RenameAsync(string projectId, string name) + { + var normalizedName = NormalizeName(name); + await _gate.WaitAsync(); + try + { + var files = await ReadProjectFilesAsync(); + var project = files.FirstOrDefault(item => item.Document.Id == projectId) + ?? throw new FileNotFoundException("Il progetto selezionato non esiste piu'."); + if (project.Document.AccessMode == "readonly") + { + throw new InvalidOperationException("Il progetto e' in sola lettura. Duplicalo per modificarlo."); + } + if (files.Any(item => item.Document.Id != projectId + && string.Equals(item.Document.Name, normalizedName, StringComparison.CurrentCultureIgnoreCase))) + { + throw new InvalidOperationException($"Esiste gia' un progetto chiamato '{normalizedName}'."); + } + + project.Document.Name = normalizedName; + project.Document.UpdatedAt = DateTimeOffset.UtcNow; + await File.WriteAllTextAsync( + project.Path, + JsonSerializer.Serialize(project.Document, _jsonOptions), + new UTF8Encoding(false)); + return project.Document.ToSummary(); + } + finally { _gate.Release(); } + } + public async Task UpdateOrganizationAsync(string projectId, string? folder = null, bool? isArchived = null) { await _gate.WaitAsync(); diff --git a/wwwroot/css/app.css b/wwwroot/css/app.css index 93af00a..d573a12 100644 --- a/wwwroot/css/app.css +++ b/wwwroot/css/app.css @@ -618,10 +618,10 @@ input[type="checkbox"] { .layer-manager-block > summary, .layer-manager-object > summary { display: grid; - grid-template-columns: 28px 28px minmax(0, 1fr) 50px; + grid-template-columns: 28px 28px minmax(0, 1fr) minmax(28px, auto); grid-template-areas: "handle expand title count" - ". . visibility moves" + "visibility lock moves moves" "actions actions actions actions"; align-items: start; gap: 6px; @@ -633,7 +633,6 @@ input[type="checkbox"] { .layer-expand-button { grid-area: expand; - grid-row: 1 / span 3; align-self: center; justify-self: center; width: 28px; @@ -733,6 +732,7 @@ input[type="checkbox"] { grid-area: moves; display: inline-grid; grid-template-columns: 24px 24px; + justify-self: end; gap: 2px; } @@ -778,12 +778,12 @@ input[type="checkbox"] { } .layer-inline-lock { - grid-area: visibility; + grid-area: lock; justify-self: center; width: 28px; min-width: 28px; min-height: 24px; - margin-left: 26px; + margin: 0; padding: 0; background: transparent; color: var(--ink); @@ -1047,6 +1047,49 @@ input[type="checkbox"] { font-size: 12px; } +.attribute-copy-panel { + display: grid; + grid-template-columns: minmax(170px, 1fr) auto; + grid-template-areas: + "heading heading" + "source source" + "toggle action" + "hint hint"; + align-items: end; + gap: 8px 12px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: #f5f8f9; +} + +.attribute-copy-panel > strong { + grid-area: heading; +} + +.attribute-copy-panel > label:first-child { + grid-area: source; + min-width: 0; +} + +.attribute-copy-panel > .toggle-line { + grid-area: toggle; +} + +.attribute-copy-panel > button { + grid-area: action; +} + +.attribute-copy-panel > label:first-child select { + width: 100%; +} + +.attribute-copy-panel > small { + grid-area: hint; + color: var(--muted); + line-height: 1.4; +} + .attribute-editor-row { display: grid; grid-template-columns: minmax(130px, 0.8fr) minmax(180px, 2fr) 36px; @@ -1111,6 +1154,47 @@ input[type="checkbox"] { text-decoration: none; } +.leaflet-help { + display: grid; + gap: 14px; +} + +.leaflet-help > p { + margin: 0; + color: var(--muted); + line-height: 1.45; +} + +.leaflet-help-list { + display: grid; + gap: 0; + margin: 0; +} + +.leaflet-help-list > div { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid var(--line); +} + +.leaflet-help-list dt { + font-size: 20px; + font-weight: 900; + text-align: center; +} + +.leaflet-help-list dd { + margin: 0; + line-height: 1.45; +} + +.leaflet-help-list dd strong { + display: block; + margin-bottom: 2px; +} + .gps-overlay { position: absolute; top: 72px; @@ -1661,6 +1745,17 @@ footer { align-self: center; } + .attribute-copy-panel { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: + "heading" + "source" + "toggle" + "action" + "hint"; + align-items: stretch; + } + .gps-overlay { top: 68px; right: 8px; diff --git a/wwwroot/js/lme-map.js b/wwwroot/js/lme-map.js index e31b48e..5cd4f66 100644 --- a/wwwroot/js/lme-map.js +++ b/wwwroot/js/lme-map.js @@ -132,6 +132,7 @@ window.lmeMap = { setProjectReadOnly, restoreAutosave, compareProjectSnapshots, + promptProjectName, setAdvancedOptions, createMapExportGeoJson, createProjectSnapshot, @@ -2277,13 +2278,25 @@ function managedHslToRgb(hue, saturation, lightness) { return values.map(channel => Math.round((channel + offset) * 255)); } +function editableManagedMetadataEntries(record) { + const properties = record?.layer?.feature?.properties || {}; + const reserved = new Set(["name", "Name", "label", "LABEL"]); + return Object.entries(properties) + .filter(([key, value]) => !key.startsWith("_lme") && !reserved.has(key) && ["string", "number", "boolean"].includes(typeof value)); +} + function editManagedAttributes(record) { if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; } - const properties = record.layer?.feature?.properties || {}; const reserved = new Set(["name", "Name", "label", "LABEL"]); - const entries = Object.entries(properties) - .filter(([key, value]) => !key.startsWith("_lme") && !reserved.has(key) && ["string", "number", "boolean"].includes(typeof value)); - const dialog = showAppDialog("Attributi oggetto", `

Ogni attributo viene salvato nel progetto e negli export GeoJSON, KML e KMZ.

`); + const entries = editableManagedMetadataEntries(record); + const sourceRecords = layerManagerCategories() + .flatMap(category => managedRecordsForCategory(category)) + .filter(item => item.id !== record.id); + const sourceOptions = sourceRecords.map((item, index) => + ``).join(""); + const dialog = showAppDialog("Metadati oggetto", `

Ogni metadato viene salvato nel progetto e negli export GeoJSON, KML e KMZ.

+
Copia da un altro oggettoDisattiva il flag per importare soltanto i nomi dei metadati con valore vuoto.
+
`); const rows = dialog.querySelector(".attribute-editor-rows"); const addRow = (key = "", value = "") => { const row = document.createElement("div"); @@ -2294,6 +2307,25 @@ function editManagedAttributes(record) { }; entries.forEach(([key, value]) => addRow(key, value)); if (!entries.length) addRow(); + const sourceById = new Map(sourceRecords.map(item => [item.id, item])); + dialog.querySelector("[data-copy-attributes]")?.addEventListener("click", () => { + const source = sourceById.get(dialog.querySelector("[data-copy-source]").value); + if (!source) { setInfo("Seleziona l'oggetto da cui copiare i metadati."); return; } + const includeValues = dialog.querySelector("[data-copy-values]").checked; + const existingRows = new Map([...rows.querySelectorAll(".attribute-editor-row")] + .map(row => [row.querySelector("input").value.trim().toLocaleLowerCase("it"), row]) + .filter(([key]) => key)); + const sourceEntries = editableManagedMetadataEntries(source); + sourceEntries.forEach(([key, value]) => { + const existing = existingRows.get(key.toLocaleLowerCase("it")); + if (existing) { + if (includeValues) existing.querySelector("textarea").value = String(value); + } else { + addRow(key, includeValues ? value : ""); + } + }); + setInfo(`${sourceEntries.length} metadati copiati${includeValues ? " con i valori" : " senza valori"}.`); + }); dialog.querySelector("[data-add-attribute]").addEventListener("click", () => addRow()); dialog.querySelector("[data-cancel]").addEventListener("click", () => dialog.remove()); dialog.querySelector("[data-save-attributes]").addEventListener("click", () => { @@ -2399,6 +2431,16 @@ function showFormDialog(title, fields, submitLabel = "Salva") { }); } +async function promptProjectName(currentName) { + const result = await showFormDialog("Rinomina progetto", [{ + name: "name", + label: "Nuovo nome", + value: currentName || "", + maxLength: 80 + }], "Rinomina"); + return result?.name?.trim() || null; +} + function showObjectTable() { const records = layerManagerCategories().flatMap(category => managedRecordsForCategory(category)); lmeState.managerRecords = new Map(records.map(record => [record.id, record])); @@ -2407,9 +2449,9 @@ function showObjectTable() { const details = managedObjectDetails(record.layer); const selected = lmeState.managerSelectedObjectIds.has(record.id); const locked = Boolean(properties._lmeLocked); - return `${escapeHtml(managedObjectLabel(record, index))}${escapeHtml(managedCategoryName(layerManagerCategories().find(item => item.key === record.categoryKey) || { key: record.categoryKey, label: record.categoryKey }, [record]))}${escapeHtml(properties.foglio || properties.FOGLIO || "")}${escapeHtml(properties.particella || properties.PARTICELLA || properties.LABEL || "")}${escapeHtml(details.slice(0, 2).join(" | "))}`; + return `${escapeHtml(managedObjectLabel(record, index))}${escapeHtml(managedCategoryName(layerManagerCategories().find(item => item.key === record.categoryKey) || { key: record.categoryKey, label: record.categoryKey }, [record]))}${escapeHtml(properties.foglio || properties.FOGLIO || "")}${escapeHtml(properties.particella || properties.PARTICELLA || properties.LABEL || "")}${escapeHtml(details.slice(0, 2).join(" | "))}`; }).join(""); - const dialog = showAppDialog("Elenco oggetti e particelle", `
Seleziona più oggetti con i flag nella colonna Selez.Il lucchetto impedisce modifiche e rimozioni.
${body || ""}
Selez.BloccaMappaNomeGruppoFoglioParticellaMisure
Nessun oggetto
`); + const dialog = showAppDialog("Elenco oggetti e particelle", `
Seleziona più oggetti con i flag nella colonna Selez.Il lucchetto impedisce modifiche e rimozioni; la matita modifica i metadati.
${body || ""}
Selez.BloccaMappaMetadatiNomeGruppoFoglioParticellaMisure
Nessun oggetto
`); const byId = new Map(records.map(record => [record.id, record])); dialog.querySelector("input[type='search']").addEventListener("input", event => { const query = event.target.value.toLocaleLowerCase("it"); @@ -2437,6 +2479,10 @@ function showObjectTable() { const record = byId.get(button.dataset.locate); if (!record) return; dialog.remove(); highlightManagedObject(record); })); + dialog.querySelectorAll("[data-attributes]").forEach(button => button.addEventListener("click", () => { + const record = byId.get(button.dataset.attributes); if (!record) return; + dialog.remove(); editManagedAttributes(record); + })); return records.length; } @@ -2729,6 +2775,27 @@ function showGeometryResult(title, message, tone = "success") { showAppDialog(title, `

${escapeHtml(message)}

`); } +function showLeafletToolbarHelp() { + showAppDialog("Guida strumenti mappa", `
+

Gli strumenti operano direttamente sulla mappa. Per le azioni su oggetti esistenti, seleziona prima le righe nel pannello Layer, nella tabella oggetti oppure tocca un oggetto sulla mappa.

+
+
+
IngrandisciAumenta lo zoom mantenendo il centro attuale. Esempio: avvicinati a un vertice per posizionarlo con maggiore precisione.
+
−
RiduciAllarga l'area visibile. Esempio: usalo per controllare insieme particelle appartenenti a fogli vicini.
+
●
Punto di interesseTocca la posizione da memorizzare. Esempio: segna un cancello, un cippo o il punto di accesso al fondo.
+
/
LineaTocca i vertici del percorso e termina con doppio tocco o sul primo comando della barra. Durante il disegno viene mostrata la distanza; utile per un fronte strada o una recinzione.
+
⬠
PoligonoTocca almeno tre vertici e chiudi la figura. Esempio: delimita un'area di intervento; l'app calcola area e perimetro.
+
□
RettangoloTrascina tra due angoli opposti. Esempio: crea rapidamente l'ingombro rettangolare di un fabbricato.
+
○
CerchioTocca il centro e trascina fino al raggio desiderato. Esempio: rappresenta una fascia di rispetto attorno a un pozzo.
+
✎
Modifica disegniPremi la matita e scegli Solo selezionato oppure Tutti. Sposta i vertici e conferma con Salva; gli oggetti bloccati devono prima essere sbloccati.
+
▱
Elimina disegniPremi il cestino e scegli Solo selezionato oppure Tutti. L'oggetto rimosso può essere recuperato con Annulla o dal cestino negli strumenti avanzati.
+
✓
Controlla e riparaVerifica gli oggetti selezionati e prova a correggere coordinate duplicate o poligoni non validi. Esempio: usalo quando un contorno importato non si esporta o si riempie male; al termine compare un riepilogo.
+
◉
Crea foroSeleziona esattamente due poligoni: prima l'area esterna, poi quella interna. Esempio: sottrai il cortile dal perimetro di un fabbricato o un'isola esclusa da un'area di lavoro.
+
⇄
Confronta due confiniSeleziona esattamente due poligoni. L'app crea il solo primo in rosso, il solo secondo in blu e la sovrapposizione in verde, mostrando le rispettive aree.
+
?
GuidaRiapre questa descrizione. I risultati di riparazione e confronto vengono mostrati anche in una finestra, così l'operazione non resta senza riscontro.
+
+
`); +} + function addGeometryToolsControl() { if (lmeState.geometryToolsControl || !lmeState.map) return; const GeometryTools = L.Control.extend({ @@ -2738,7 +2805,8 @@ function addGeometryToolsControl() { const actions = [ { icon: "✓", title: "Controlla e ripara geometrie", run: validateAndRepairSelected }, { icon: "◉", title: "Crea foro da due poligoni", run: createHoleFromSelected }, - { icon: "⇄", title: "Confronta due confini", run: compareSelectedGeometries } + { icon: "⇄", title: "Confronta due confini", run: compareSelectedGeometries }, + { icon: "?", title: "Guida strumenti mappa", run: showLeafletToolbarHelp } ]; actions.forEach(action => { const button = L.DomUtil.create("a", "", container);