diff --git a/Components/MenuHelp.razor b/Components/MenuHelp.razor index f19d848..98be705 100644 --- a/Components/MenuHelp.razor +++ b/Components/MenuHelp.razor @@ -24,6 +24,7 @@ { "layers" => "Qui controlli visibilita, ordine, selezione e organizzazione di tutti gli oggetti presenti sulla mappa.", "cadastre" => "Trova una particella nel catalogo catastale e mostrane il confine ufficiale sulla mappa.", + "fiducials" => "Ricerca i Punti Fiduciali di un foglio, consultali sulla mappa e scarica le monografie disponibili.", "data" => "Importa file GIS, crea disegni e misure oppure aggiungi punti di interesse.", "save" => "Esporta gli elementi della mappa in un file compatibile con altre applicazioni GIS e GPS.", "projects" => "Salva il lavoro completo, riaprilo, organizzalo e recupera revisioni o copie di sicurezza.", @@ -40,6 +41,7 @@ "Gerarchia: premi la freccia accanto alla maniglia per aprire un gruppo. Il rientro, il carattere e la tonalita indicano se una riga e figlia, nipote o di livello successivo.", "Selezione multipla: attiva il flag sulle righe desiderate. Esempio: seleziona tre poligoni per esportare solo quelli oppure per bloccarli con una sola operazione.", "Rinomina e attributi: Rinomina modifica il nome mostrato e salvato nei progetti e nei file esportati; Attributi apre un editor multilinea per consultare o correggere i dati associati.", + "Note: espandi un oggetto e premi Visualizza note per consultarne l'elenco. Con LME Pro, tramite abbonamento o licenza manuale, puoi aggiungere, modificare ed eliminare un numero qualsiasi di note; le vecchie note singole restano compatibili.", "Colori: espandi un padre o un figlio e usa gli editor di linea e riempimento. Puoi scegliere il colore, prelevarlo con il contagocce se supportato, oppure inserire e copiare i valori HEX, RGB e HSL; sul padre viene applicato a tutto il gruppo.", "Blocco: il lucchetto impedisce modifiche e spostamenti involontari. Sblocca l'oggetto prima di usare la matita o gli strumenti geometrici.", "Sposta nel gruppo cambia l'appartenenza. Esempio: trasferisci un poligono da Disegni e POI a Progetti e dati importati, oppure in un nuovo sottogruppo chiamato Rilievo nord.", @@ -56,6 +58,16 @@ "Se un oggetto gia presente era nascosto, viene reso nuovamente visibile. La visibilita generale dei contorni evidenziati e dei dati importati resta controllabile dal pannello Layer.", "Quando il servizio ufficiale non restituisce subito il confine, attendi il completamento indicato nel pannello e riprova mantenendo gli stessi dati selezionati." ], + "fiducials" => + [ + "La funzione richiede LME Pro: sono validi sia l'abbonamento Google Play sia una licenza manuale attiva.", + "Scegli Provincia, Comune catastale e Foglio, quindi premi Cerca Punti Fiduciali. Puoi anche usare il comando PF sulla mappa dopo aver selezionato una particella o un foglio, oppure dalla particella individuata dal GPS.", + "Mantieni i PF dei fogli gia caricati conserva i risultati precedenti come sottogruppi distinti; disattivalo per sostituirli con il nuovo foglio.", + "Il flag Mostra i PF sulla mappa controlla la visibilita generale. Nel pannello Layer puoi inoltre cambiare colore, ordine, visibilita o rimuovere il singolo gruppo del foglio.", + "Tocca un punto rosso per vedere identificativo e coordinate. Quando disponibile, Scarica PDF monografia apre o salva il documento ufficiale dell'Agenzia delle Entrate.", + "I PF caricati vengono salvati nel progetto con gruppi, popup e collegamenti alle monografie; riaprendo il progetto tornano disponibili come gli altri oggetti.", + "Il posizionamento cartografico e sperimentale e non sostituisce un rilievo topografico o la verifica professionale sul posto." + ], "data" => [ "Importazione: premi Carica KML, KMZ o GeoJSON e scegli il file dal dispositivo. I nomi e gli attributi riconosciuti vengono mantenuti.", @@ -75,11 +87,11 @@ "Attiva solo le categorie da includere. Esempio: per consegnare disegni e particelle importate senza la traccia GPS, lascia attive quelle due voci e disattiva Traccia GPS.", "Solo oggetti selezionati esporta esclusivamente le righe contrassegnate nel pannello Layer. Se non devi filtrare manualmente, lascia questa opzione disattivata.", "Premi Esporta e salva file e scegli la cartella o l'app di destinazione proposta dal dispositivo.", - "Nomi rinominati e attributi vengono salvati in GeoJSON, KML e KMZ. Gli sfondi satellitari, stradali e WMS non vengono incorporati nei file vettoriali." + "Nomi rinominati, attributi e note compatibili vengono salvati nei formati che supportano metadati. Gli sfondi satellitari, stradali e WMS non vengono incorporati nei file vettoriali." ], "projects" => [ - "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.", + "Nuovo progetto: inserisci un nome, scegli se includere i contorni catastali evidenziati e premi Salva progetto. Vengono conservati oggetti, gruppi, nomi, attributi, note, Punti Fiduciali caricati 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.", "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.", @@ -91,6 +103,7 @@ "tools" => [ "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.", + "Colonna Note: il pulsante Note e sempre attivo e apre tutte le note dell'oggetto. La consultazione resta disponibile anche senza Pro; aggiunta, modifica ed eliminazione richiedono abbonamento o licenza manuale.", "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.", @@ -102,6 +115,8 @@ "settings" => [ "Mappa e sfondo: scegli la cartografia di base e regola Trasparenza catasto per leggere meglio confini e immagini sottostanti. Esempio: riducila quando le linee coprono dettagli del terreno.", + "Bussola: attiva Mostra bussola per visualizzare N, NE, E, SE, S, SW, W e NW. Puoi trascinarla nella posizione preferita. Con nord fisso la mappa non ruota e la lancetta indica il nord; in modalita Navigatore la mappa segue l'orientamento del dispositivo.", + "Messaggi mappa: scegli dopo quanti secondi devono chiudersi automaticamente; imposta 0 per mantenerli finche non premi la X.", "Autosalvataggio locale dopo ogni modifica: se attivo, ogni disegno, modifica, rinomina o eliminazione aggiorna immediatamente una copia di recupero nel dispositivo. Per usarla apri Dati e disegni e premi Recupera ultimo autosalvataggio.", "Aggiorna automaticamente il progetto aperto: funziona solo dopo aver aperto o salvato un progetto modificabile. Dopo una modifica attende 3 secondi senza altre operazioni e aggiorna quel progetto; un progetto in sola lettura non viene mai sovrascritto.", "Backup automatico: scegli Giornaliero o Settimanale. Alla successiva pressione di Salva progetto, se e trascorso l'intervallo, viene creata una copia interna di tutti i progetti; l'app conserva le 10 copie automatiche piu recenti. Disattivato non crea nuove copie programmate.", @@ -109,7 +124,8 @@ "Aggancio ai vertici: avvicinando un nuovo vertice a uno esistente, l'app lo allinea entro la distanza in pixel scelta. Aumenta la distanza su schermi piccoli; riducila quando ci sono molti vertici ravvicinati.", "Centra e zooma quando seleziono un oggetto: attivalo per inquadrare automaticamente ogni elemento scelto; disattivalo se vuoi mantenere ferma la vista mentre lavori dall'elenco Layer.", "WebDAV: inserisci indirizzo, utente e password applicazione, salva la configurazione, poi usa Invia al cloud per caricare il backup o Scarica dal cloud per ripristinare quello remoto.", - "GPS e percorso: Mostra punto visualizza posizione e precisione; Registra traccia memorizza il cammino; distanza e avvisi aiutano a controllare l'avvicinamento e l'attraversamento del confine selezionato.", + "GPS e percorso: Mostra punto visualizza posizione e precisione; Registra traccia memorizza il cammino; distanza e avvisi aiutano a controllare l'avvicinamento e l'attraversamento del confine selezionato. Avvia/Ferma GPS e Centra sono disposti verticalmente e il riquadro puo essere compresso.", + "Fogli catastali in navigazione: con Fogli catastali attivo nel pannello Layer, l'app carica il foglio anche senza particelle o progetti e lo mantiene visibile a zoom piu ampi senza duplicarlo se e gia presente.", "Google Maps e Azure Maps richiedono le rispettive chiavi solo quando scegli quegli sfondi. Controlla nuova versione confronta la versione installata con l'ultima pubblicata e mostra le relative modifiche." ], _ => [] diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index 5a692c1..8675ecb 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -309,6 +309,7 @@

Elenco e PDF provengono dall'Agenzia delle Entrate; il posizionamento sperimentale usa il servizio cartografico GloboGIS. Le coordinate sono un ausilio: verificale prima di qualsiasi uso topografico o professionale.

} + } else if (ActiveTab == "data") @@ -2538,6 +2539,7 @@ { advancedGeometry = CanUse(AppFeatures.AdvancedGeometry), fiducialPoints = CanUse(AppFeatures.FiducialPoints), + multipleNotes = CanUse(AppFeatures.MultipleNotes), maxDrawingObjects = Entitlements.HasProAccess ? (int?)null : EntitlementService.BaseDrawingObjectLimit, drawingLimitMessage = L("drawings.base.limit.reached") }).AsTask(); diff --git a/LMEMappeCatastaliApp.csproj b/LMEMappeCatastaliApp.csproj index 8e1c816..d76c313 100644 --- a/LMEMappeCatastaliApp.csproj +++ b/LMEMappeCatastaliApp.csproj @@ -29,8 +29,8 @@ it.liviomerola.lmemappecatastali.pro - 1.0.18 - 118 + 1.0.19 + 119 $(ApplicationDisplayVersion) diff --git a/Services/EntitlementService.cs b/Services/EntitlementService.cs index d98c38b..d162398 100644 --- a/Services/EntitlementService.cs +++ b/Services/EntitlementService.cs @@ -22,7 +22,8 @@ public sealed class EntitlementService( AppFeatures.OfflineMaps, AppFeatures.AdvancedGeometry, AppFeatures.GpsTrail, - AppFeatures.FiducialPoints + AppFeatures.FiducialPoints, + AppFeatures.MultipleNotes }; public bool IsGoogleProActive { get; private set; } @@ -187,4 +188,5 @@ public static class AppFeatures public const string AdvancedGeometry = "advanced-geometry"; public const string GpsTrail = "gps-trail"; public const string FiducialPoints = "fiducial-points"; + public const string MultipleNotes = "multiple-notes"; } diff --git a/wwwroot/css/app.css b/wwwroot/css/app.css index e870cfe..374fe12 100644 --- a/wwwroot/css/app.css +++ b/wwwroot/css/app.css @@ -906,6 +906,81 @@ input[type="checkbox"] { padding: 0; } +.object-table-wrap .object-notes-button { + width: auto; + min-width: 70px; + padding: 4px 8px; + white-space: nowrap; +} + +.managed-notes-command { + display: grid; + gap: 5px; +} + +.managed-notes-command small, +.managed-note-pro-message { + color: var(--muted); + font-size: 11px; +} + +.managed-notes-list { + display: grid; + gap: 8px; +} + +.managed-note { + padding: 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: #f8fafb; +} + +.managed-note > header { + padding: 0 0 7px; + border: 0; +} + +.managed-note time { + color: var(--muted); + font-size: 11px; +} + +.managed-note p, +.lme-popup-notes p { + margin: 0 0 8px; + white-space: normal; + overflow-wrap: anywhere; +} + +.managed-note-actions { + display: flex; + gap: 8px; +} + +.managed-note-actions button { + min-height: 34px; +} + +.managed-note-editor { + display: grid; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--line); +} + +.managed-note-editor label { + display: grid; + gap: 6px; + font-weight: 700; +} + +.managed-note-editor textarea { + min-height: 90px; + resize: vertical; +} + .object-table-search, .coordinate-result { display: grid; diff --git a/wwwroot/js/lme-map.js b/wwwroot/js/lme-map.js index 9ac8d1f..56a2039 100644 --- a/wwwroot/js/lme-map.js +++ b/wwwroot/js/lme-map.js @@ -119,6 +119,8 @@ const lmeState = { measurementSystem: "metric", entitlements: { advancedGeometry: false, + fiducialPoints: false, + multipleNotes: false, maxDrawingObjects: 15, drawingLimitMessage: "Hai raggiunto il limite di 15 oggetti del piano Base." }, @@ -2476,6 +2478,89 @@ function managedGroupTotals(records) { return values.join(" | ") || `${records.length} oggetti`; } +function managedNotes(properties = {}) { + const stored = Array.isArray(properties._lmeNotesList) ? properties._lmeNotesList : []; + const notes = stored.map((item, index) => { + const value = typeof item === "string" ? { text: item } : (item || {}); + const text = String(value.text || "").trim(); + return text ? { + id: String(value.id || `note-${index}`), + text, + createdAt: value.createdAt || null, + updatedAt: value.updatedAt || null + } : null; + }).filter(Boolean); + const legacy = String(properties._lmeNotes || "").trim(); + if (!notes.length && legacy) { + notes.push({ id: "legacy-note", text: legacy, createdAt: null, updatedAt: null }); + } + return notes; +} + +function writeManagedNotes(properties, notes) { + properties._lmeNotesList = notes.map(note => ({ + id: String(note.id), + text: String(note.text || "").trim(), + createdAt: note.createdAt || null, + updatedAt: note.updatedAt || null + })).filter(note => note.text); + properties._lmeNotes = properties._lmeNotesList.map(note => note.text).join("\n\n"); +} + +function managedNoteId() { + return globalThis.crypto?.randomUUID?.() || `note-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function managedNoteDate(note) { + const value = note.updatedAt || note.createdAt; + if (!value) return "Nota importata"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "Nota" : date.toLocaleString("it-IT"); +} + +function showManagedNotesDialog(record) { + const properties = record.layer?.feature?.properties || {}; + const title = `Note - ${managedObjectEditableName(record) || "Oggetto"}`; + const notes = managedNotes(properties); + const canEdit = Boolean(lmeState.entitlements?.multipleNotes) && !lmeState.readOnly; + const list = notes.map((note, index) => `
+
Nota ${index + 1}
+

${escapeHtml(note.text).replaceAll("\n", "
")}

+ ${canEdit ? `
` : ""} +
`).join(""); + const editor = canEdit ? `
+ + +
` : `

${lmeState.readOnly ? "Il progetto e' in sola lettura: le note possono essere consultate ma non modificate." : "La consultazione e' disponibile; per aggiungere, modificare o eliminare note serve LME Pro con abbonamento o licenza manuale."}

`; + const dialog = showAppDialog(title, `
${list || "

Nessuna nota presente.

"}
${editor}`); + dialog.querySelector(".managed-note-editor")?.addEventListener("submit", event => { + event.preventDefault(); + if (!requireProFeature("multipleNotes")) return; + const text = String(event.currentTarget.elements.note?.value || "").trim(); + if (!text) return; + const now = new Date().toISOString(); + updateManagedProperties([record], value => writeManagedNotes(value, [...managedNotes(value), { id: managedNoteId(), text, createdAt: now }])); + showManagedNotesDialog(record); + }); + dialog.querySelectorAll("[data-edit-note]").forEach(button => button.addEventListener("click", async () => { + if (!requireProFeature("multipleNotes")) return; + const current = managedNotes(record.layer?.feature?.properties || {}).find(note => note.id === button.dataset.editNote); + if (!current) return; + const result = await showFormDialog("Modifica nota", [{ name: "note", label: "Testo", type: "textarea", rows: 6, maxLength: 1000, value: current.text }], "Salva nota"); + if (!result) { showManagedNotesDialog(record); return; } + const text = String(result.note || "").trim(); + if (!text) { showManagedNotesDialog(record); return; } + updateManagedProperties([record], value => writeManagedNotes(value, managedNotes(value).map(note => note.id === current.id ? { ...note, text, updatedAt: new Date().toISOString() } : note))); + showManagedNotesDialog(record); + })); + dialog.querySelectorAll("[data-delete-note]").forEach(button => button.addEventListener("click", () => { + if (!requireProFeature("multipleNotes")) return; + updateManagedProperties([record], value => writeManagedNotes(value, managedNotes(value).filter(note => note.id !== button.dataset.deleteNote))); + showManagedNotesDialog(record); + })); + return dialog; +} + function renderManagedObjectTools(record) { const properties = record.layer.feature?.properties || (record.layer.feature = { type: "Feature", properties: {} }).properties; const selected = lmeState.managerSelectedObjectIds.has(record.id); @@ -2493,7 +2578,7 @@ function renderManagedObjectTools(record) { - +
Le note multiple sono modificabili con LME Pro.
${properties._lmePhoto ? `Foto allegata` : ""}
`; @@ -2514,7 +2599,7 @@ function renderManagedObjectTools(record) { }); bindManagedColorEditors(tools, applyCurrentStyle); tools.querySelectorAll(".layer-style-controls input[type='range'],.layer-style-controls select").forEach(input => input.addEventListener("change", applyCurrentStyle)); - tools.querySelector("[data-field='notes']").addEventListener("change", event => updateManagedProperties([record], value => { value._lmeNotes = event.target.value.trim(); })); + tools.querySelector("[data-action='notes']").addEventListener("click", () => showManagedNotesDialog(record)); tools.querySelector("[data-field='photo']").addEventListener("change", async event => { const file = event.target.files?.[0]; if (!file) return; if (file.size > 2_500_000) { setInfo("Foto troppo grande: usa un file inferiore a 2,5 MB."); return; } @@ -2831,9 +2916,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; la matita modifica i metadati.
${body || ""}
Selez.BloccaMappaMetadatiNomeGruppoFoglioParticellaMisure
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.Note apre l'elenco completo; le modifiche richiedono LME Pro.
${body || ""}
Selez.BloccaMappaMetadatiNoteNomeGruppoFoglioParticellaMisure
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"); @@ -2865,6 +2950,10 @@ function showObjectTable() { const record = byId.get(button.dataset.attributes); if (!record) return; dialog.remove(); editManagedAttributes(record); })); + dialog.querySelectorAll("[data-notes]").forEach(button => button.addEventListener("click", () => { + const record = byId.get(button.dataset.notes); if (!record) return; + showManagedNotesDialog(record); + })); return records.length; } @@ -3180,8 +3269,8 @@ function showLeafletToolbarHelp() {
✓
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.
-
PF
Scarica Punti FiducialiSeleziona una particella o un foglio catastale; in alternativa usa la particella rilevata dal GPS. I PF vengono aggiunti come sottogruppo del relativo foglio.
-
?
GuidaRiapre questa descrizione. I risultati di riparazione e confronto vengono mostrati anche in una finestra, così l'operazione non resta senza riscontro.
+
PF
Scarica Punti FiducialiSeleziona una particella o un foglio catastale; in alternativa usa la particella rilevata dal GPS. I PF vengono aggiunti come sottogruppo del relativo foglio e richiedono LME Pro.
+
?
GuidaRiapre questa descrizione. I risultati di riparazione e confronto vengono mostrati anche in una finestra; per uscire dal confronto usa Annulla oppure avvia un nuovo confronto.
`); } @@ -5453,11 +5542,12 @@ function bindStoredFeaturePopup(feature, layer) { function updateStoredFeaturePopup(layer, sourceProperties) { const properties = sourceProperties || layer?.feature?.properties || {}; const label = properties.name || properties.Name || properties.label || properties.LABEL; - const notes = String(properties._lmeNotes || "").trim(); + const notes = managedNotes(properties); const photo = String(properties._lmePhoto || ""); - if (label || notes || photo) { + if (label || notes.length || photo) { const image = photo.startsWith("data:image/") ? `Foto allegata` : ""; - layer.bindPopup(`${label ? `${escapeHtml(label)}` : ""}${notes ? `

${escapeHtml(notes)}

` : ""}${image}`); + const noteList = notes.length ? `
Note${notes.map(note => `

${escapeHtml(note.text).replaceAll("\n", "
")}

`).join("")}
` : ""; + layer.bindPopup(`${label ? `${escapeHtml(label)}` : ""}${noteList}${image}`); return; } if (typeof layer?.unbindPopup === "function") {