From cfba1b998b5bfd5d78202a4a7837ec66d70547ba Mon Sep 17 00:00:00 2001 From: livio Date: Fri, 25 Sep 2026 14:13:37 +0200 Subject: [PATCH] Release 0.4.3 with contextual help and parcel deduplication --- Components/MenuHelp.razor | 113 ++++++++++++++++++++++++++ Components/Pages/Home.razor | 19 ++++- LMEMappeCatastaliApp.csproj | 4 +- README.md | 8 ++ Services/MapExportService.cs | 2 +- Services/OfficialCadastreService.cs | 2 +- wwwroot/css/app.css | 67 +++++++++++++++ wwwroot/js/lme-map.js | 121 ++++++++++++++++++++++++++-- 8 files changed, 325 insertions(+), 11 deletions(-) create mode 100644 Components/MenuHelp.razor diff --git a/Components/MenuHelp.razor b/Components/MenuHelp.razor new file mode 100644 index 0000000..93f681a --- /dev/null +++ b/Components/MenuHelp.razor @@ -0,0 +1,113 @@ + + +@code { + [Parameter, EditorRequired] + public string Topic { get; set; } = string.Empty; + + private string Introduction => Topic switch + { + "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.", + "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.", + "tools" => "Consulta e gestisci gli oggetti in modo avanzato, controlla le coordinate e prepara mappe offline.", + "settings" => "Personalizza mappa, salvataggi, GPS, sincronizzazione e servizi esterni.", + _ => "Consulta le indicazioni essenziali per usare questo pannello." + }; + + private IReadOnlyList Instructions => Topic switch + { + "layers" => + [ + "Visibilita: usa la casella di una categoria o di un gruppo per mostrare o nascondere tutti i suoi contenuti; la casella del singolo oggetto agisce solo su quello.", + "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.", + "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.", + "Sposta geometria cambia invece le coordinate dell'oggetto, per esempio 100 metri verso nord; non modifica il gruppo di appartenenza.", + "Rimuovi dal progetto nasconde l'elemento e lo esclude dai salvataggi successivi; Elimina lo sposta nel cestino. Usa gli strumenti avanzati per ripristinare l'ultima rimozione o svuotare definitivamente il cestino.", + "Ordine: trascina la maniglia di una riga verso l'alto o il basso per cambiare la sovrapposizione sulla mappa." + ], + "cadastre" => + [ + "Compila i campi nell'ordine Regione, Comune, Foglio e Particella: ogni scelta carica l'elenco necessario al campo successivo.", + "Esempio: scegli Campania, il Comune, il foglio 12 e la particella 345, poi premi Mostra particella sulla mappa.", + "Il confine ufficiale viene evidenziato in rosso. Se Centra e zooma quando seleziono un oggetto e attivo nelle Impostazioni, la mappa lo inquadra automaticamente.", + "Se la particella e gia presente tra disegni, progetti o file GeoJSON, KML e KMZ, l'app riconosce il riferimento catastale o la geometria e riutilizza l'oggetto esistente senza duplicarlo.", + "Se un oggetto gia presente era nascosto, viene reso nuovamente visibile. La visibilita generale dei contorni rossi 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." + ], + "data" => + [ + "Importazione: premi Carica KML, KMZ o GeoJSON e scegli il file dal dispositivo. I nomi e gli attributi riconosciuti vengono mantenuti.", + "Per KML e KMZ, Includi anche il layer del foglio importa pure cornici e riquadri generali; disattivalo quando vuoi conservare soltanto le particelle riconosciute.", + "Usa Mostra dati importati e Mostra cornice per controllare separatamente la visibilita. Inquadra dati porta la mappa sull'estensione completa del file.", + "Disegno: premi Mostra strumenti sulla mappa, torna alla mappa e scegli linea, poligono, rettangolo, cerchio o POI nella barra Leaflet.", + "Modifica ed eliminazione possono agire sull'oggetto selezionato o su tutti i disegni. Prima seleziona sulla mappa la linea o il poligono interessato, poi scegli l'ambito dal piccolo comando accanto alla matita o al cestino.", + "Annulla e Ripristina coprono disegni, spostamenti, rinomine ed eliminazioni recenti. Recupera ultimo autosalvataggio ricarica invece l'ultima copia locale creata automaticamente.", + "Esempio POI: porta il centro della mappa sull'ingresso di un terreno, scrivi Cancello nord e premi Aggiungi POI al centro mappa.", + "Per rinominare, bloccare o trasferire un oggetto in un altro gruppo, apri Layer e usa i comandi della relativa riga." + ], + "save" => + [ + "Inserisci un nome riconoscibile, per esempio Rilievo particelle 12-345, poi scegli il formato in base all'uso previsto.", + "GeoJSON e adatto ai software GIS e conserva geometrie, nomi e attributi; KML e KMZ sono adatti a Google Earth, con KMZ piu compatto per invio e archiviazione.", + "GPX e pensato per dispositivi e applicazioni GPS; PDF crea un rapporto consultabile e stampabile invece di un file vettoriale modificabile.", + "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." + ], + "projects" => + [ + "Nuovo progetto: inserisci un nome, scegli se includere i contorni catastali rossi 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.", + "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.", + "Backup: Crea backup raccoglie tutti i progetti in un file trasferibile. Ripristina backup ricarica un archivio completo; Importa progetto aggiunge invece un singolo progetto condiviso." + ], + "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.", + "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.", + "Mappe offline: prima inquadra sulla mappa la zona che ti servira sul campo, quindi scegli quanti livelli di zoom aggiuntivi scaricare. Valori maggiori offrono piu dettaglio ma richiedono piu spazio e tempo.", + "Esempio offline: inquadra il cantiere e le strade di accesso, imposta 2 livelli aggiuntivi e premi Scarica area visibile mentre hai connessione.", + "Usa mappe offline seleziona lo sfondo salvato localmente; Svuota archivio libera lo spazio occupato dalle tessere scaricate." + ], + "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.", + "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.", + "Esempio consigliato: lascia attivo l'autosalvataggio locale, attiva l'aggiornamento del progetto aperto durante i rilievi e imposta un backup giornaliero. Prima di modifiche importanti crea anche un backup manuale dal menu Progetti.", + "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.", + "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 9faaea7..abcf7ac 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -102,6 +102,7 @@ + } @@ -189,6 +190,7 @@ Mappa pubblica ufficiale

I confini visualizzati arrivano dai servizi WMS/WFS open data dell'Agenzia delle Entrate e non richiedono CAPTCHA.

+ } else if (ActiveTab == "data") @@ -253,6 +255,7 @@ + } else if (ActiveTab == "save") @@ -310,6 +313,7 @@ KML e KMZ sono compatibili con Google Earth. Gli sfondi satellitari e il WMS non vengono incorporati nel file vettoriale. + } else if (ActiveTab == "projects") @@ -405,6 +409,7 @@ Mostra misure dei disegni + } else if (ActiveTab == "tools") @@ -436,6 +441,7 @@ @OfflineStatus + } else if (ActiveTab == "settings") @@ -587,6 +593,7 @@
Dati catastali: Agenzia delle Entrate, licenza CC BY 4.0. App indipendente.
+ } @@ -1093,8 +1100,16 @@ var label = $"{municipality.Name} - foglio {DisplaySheet(SelectedSheet)}, particella {parcel.Number}"; CatalogBusy = true; CatalogMessage = "Scarico il confine dal servizio ufficiale AdE..."; - var xml = await OfficialCadastreService.GetParcelsNearAsync(parcel.Latitude, parcel.Longitude, 8); - await JS.InvokeVoidAsync("lmeMap.displayOfficialParcel", xml, parcel.Latitude, parcel.Longitude, parcel.Number, label, true); + ShowSelectedParcels = true; + SaveFlagPreferences(); + await JS.InvokeVoidAsync("lmeMap.setSelectedParcelsVisible", true); + var xml = await OfficialCadastreService.GetParcelsNearAsync(parcel.Latitude, parcel.Longitude, 30); + var displayResult = await JS.InvokeAsync("lmeMap.displayOfficialParcel", xml, parcel.Latitude, parcel.Longitude, parcel.Number, label, true); + if (displayResult == "imported") + { + ShowImportedData = true; + SaveFlagPreferences(); + } ActiveTab = "map"; await Task.Delay(30); await JS.InvokeVoidAsync("lmeMap.resize"); diff --git a/LMEMappeCatastaliApp.csproj b/LMEMappeCatastaliApp.csproj index e65523d..67ad1c9 100644 --- a/LMEMappeCatastaliApp.csproj +++ b/LMEMappeCatastaliApp.csproj @@ -28,8 +28,8 @@ com.companyname.lmemappecatastaliapp - 0.4.2 - 37 + 0.4.3 + 38 $(ApplicationDisplayVersion) diff --git a/README.md b/README.md index 3b5f82f..2e738d1 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.3 + +- `Mostra particella sulla mappa` riattiva sempre il layer dei confini rossi e lo porta in primo piano. +- Una particella gia evidenziata o temporaneamente nascosta viene recuperata senza creare duplicati. +- Deduplicazione estesa alle particelle presenti in progetti e file GeoJSON, KML e KMZ, tramite riferimenti catastali o confronto geometrico. +- Ricerca WFS ampliata per rendere piu affidabile il recupero dei confini di particelle estese o irregolari. +- Guida rapida collassabile in fondo a Layer e a ogni pannello operativo, con istruzioni specifiche per le relative funzioni. + ## Versione 0.4.2 - Nuovo comando `Sposta nel gruppo` visibile accanto a Rinomina e Rimuovi per padri, sottogruppi e singoli oggetti. diff --git a/Services/MapExportService.cs b/Services/MapExportService.cs index f18cfc9..5ee2ace 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.2"), + new XAttribute("creator", "LME Mappe Catastali 0.4.3"), 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/OfficialCadastreService.cs b/Services/OfficialCadastreService.cs index e81d60b..c399d3d 100644 --- a/Services/OfficialCadastreService.cs +++ b/Services/OfficialCadastreService.cs @@ -44,7 +44,7 @@ public sealed class OfficialCadastreService ["SRSNAME"] = "urn:ogc:def:crs:EPSG::6706", ["BBOX"] = bbox, ["REQUEST"] = "GetFeature", - ["COUNT"] = "20" + ["COUNT"] = "100" }; var url = WfsEndpoint + "?" + string.Join("&", query.Select(item => diff --git a/wwwroot/css/app.css b/wwwroot/css/app.css index c9975ea..c42c2d1 100644 --- a/wwwroot/css/app.css +++ b/wwwroot/css/app.css @@ -306,6 +306,73 @@ input[type="checkbox"] { padding-top: 9px; } +.menu-help { + margin-top: 4px; + border: 1px solid #c8d3d8; + border-radius: 6px; + background: #f7fafb; + color: var(--ink); +} + +.menu-help summary { + display: flex; + align-items: center; + gap: 8px; + min-height: 40px; + padding: 7px 10px; + cursor: pointer; + list-style: none; + font-size: 12px; +} + +.menu-help summary::-webkit-details-marker { + display: none; +} + +.menu-help summary > span { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border: 1px solid #82949d; + border-radius: 50%; + color: #42535c; + font-weight: 900; +} + +.menu-help summary::after { + margin-left: auto; + content: "\25BE"; + color: #52636b; + font-size: 14px; + transition: transform 140ms ease; +} + +.menu-help[open] summary::after { + transform: rotate(180deg); +} + +.menu-help-content { + padding: 10px 12px 12px; + border-top: 1px solid #d8e0e4; + font-size: 11px; + line-height: 1.45; +} + +.menu-help-content p { + margin: 0 0 8px; + font-weight: 700; +} + +.menu-help-content ol { + margin: 0; + padding-left: 18px; +} + +.menu-help-content li + li { + margin-top: 6px; +} + .layer-tree-section { display: grid; gap: 6px; diff --git a/wwwroot/js/lme-map.js b/wwwroot/js/lme-map.js index eac5242..ef181bd 100644 --- a/wwwroot/js/lme-map.js +++ b/wwwroot/js/lme-map.js @@ -367,7 +367,7 @@ function displayOfficialParcel(xmlText, latitude, longitude, parcel, label, shou const lon = Number(longitude); if (!Number.isFinite(lat) || !Number.isFinite(lon)) { setInfo("Coordinate della particella non valide."); - return; + return "invalid"; } try { @@ -389,27 +389,63 @@ function displayOfficialParcel(xmlText, latitude, longitude, parcel, label, shou lmeState.map.setView([lat, lon], 20); } setInfo("Posizione trovata, ma il servizio WFS dell'Agenzia non ha restituito il confine. Riprova tra poco."); - return; + return "missing"; } const ref = feature.properties?.NATIONALCADASTRALREFERENCE || feature.properties?.INSPIREID_LOCALID || label; const targetGroup = normalizedParcel ? lmeState.selectedParcelsLayer : lmeState.gpsParcelLayer; + if (normalizedParcel) { + lmeState.selectedParcelsVisible = true; + lmeState.managerHiddenCategories.delete("selectedParcels"); + if (!lmeState.map.hasLayer(targetGroup)) { + targetGroup.addTo(lmeState.map); + } + targetGroup.bringToFront(); + } if (!normalizedParcel) { targetGroup.clearLayers(); } const existingLayer = normalizedParcel - ? findParcelLayerByReference(targetGroup, ref) + ? findParcelLayerByReference(targetGroup, ref) || restoreHiddenParcelLayerByReference(ref) : null; if (existingLayer) { + if (typeof existingLayer.setStyle === "function") { + existingLayer.setStyle({ color: "#ef4444", weight: 4, opacity: 1, fillColor: "#fbbf24", fillOpacity: 0.26 }); + } + targetGroup.bringToFront(); if (shouldFit) { fitLayer(existingLayer); } setText("parcelState", `Particella selezionata: ${ref}`); setInfo(`La particella ${ref} e' gia evidenziata sulla mappa.`); - return; + updateGpsInsideState(); + refreshLayerManager(); + return "selected"; } + const importedRecord = normalizedParcel ? findImportedParcelRecord(feature, ref) : null; + if (importedRecord) { + restoreManagedRecordVisibility(importedRecord); + lmeState.importedVisible = true; + lmeState.managerHiddenCategories.delete("imported"); + if (!lmeState.map.hasLayer(lmeState.imported)) { + lmeState.imported.addTo(lmeState.map); + } + lmeState.imported.bringToFront(); + if (shouldFit) { + fitLayer(importedRecord.layer); + } + setText("parcelState", `Particella gia presente nei dati importati: ${ref}`); + setInfo(`La particella ${ref} e' gia presente in un progetto o file importato: nessun duplicato aggiunto.`); + refreshLayerManager(); + return "imported"; + } + + feature.properties = { + ...(feature.properties || {}), + _lmeParcelReference: String(ref || label || normalizedParcel) + }; const layer = L.geoJSON(feature, { pane: normalizedParcel ? "selectedParcelsPane" : "gpsParcelsPane", style: normalizedParcel @@ -418,6 +454,9 @@ function displayOfficialParcel(xmlText, latitude, longitude, parcel, label, shou }).addTo(targetGroup); layer.lmeParcelReference = ref; layer.bindPopup(`${escapeHtml(ref)}`); + if (normalizedParcel) { + targetGroup.bringToFront(); + } if (shouldFit) { fitLayer(layer); } @@ -429,9 +468,12 @@ function displayOfficialParcel(xmlText, latitude, longitude, parcel, label, shou } else { setText("parcelState", `Dentro particella AdE: ${ref}`); } + updateGpsInsideState(); refreshLayerManager(); + return normalizedParcel ? "added" : "gps"; } catch (error) { setInfo(`Geometria catastale non leggibile: ${error.message}`); + return "error"; } } @@ -440,12 +482,13 @@ function normalizeParcel(parcel) { } function findParcelLayerByReference(group, reference) { + const expected = normalizeParcelReference(reference); let match = null; group.eachLayer(layer => { if (match) { return; } - if (layer.lmeParcelReference === reference) { + if (parcelLayerMatchesReference(layer, expected)) { match = layer; return; } @@ -456,6 +499,74 @@ function findParcelLayerByReference(group, reference) { return match; } +function restoreHiddenParcelLayerByReference(reference) { + const expected = normalizeParcelReference(reference); + const entry = [...lmeState.managerHiddenObjects.entries()].find(([, record]) => + record.categoryKey === "selectedParcels" && parcelLayerMatchesReference(record.layer, expected)); + if (!entry) return null; + const [id, record] = entry; + lmeState.managerHiddenObjects.delete(id); + lmeState.managerHiddenObjectIds.delete(id); + lmeState.managerHiddenMeasurementSummaryIds.delete(id); + lmeState.managerHiddenMeasurementSideIds.delete(id); + record.parent?.addLayer?.(record.layer); + return record.layer; +} + +function parcelLayerMatchesReference(layer, normalizedReference) { + const properties = layer?.feature?.properties || {}; + return [ + layer?.lmeParcelReference, + properties._lmeParcelReference, + properties.NATIONALCADASTRALREFERENCE, + properties.INSPIREID_LOCALID + ].some(value => normalizeParcelReference(value) === normalizedReference); +} + +function findImportedParcelRecord(feature, reference) { + const category = layerManagerCategories().find(item => item.key === "imported"); + if (!category) return null; + const normalizedReference = normalizeParcelReference(reference); + return managedRecordsForCategory(category).find(record => + parcelLayerMatchesReference(record.layer, normalizedReference) + || parcelGeometriesEquivalent(record.layer?.toGeoJSON?.(), feature)) || null; +} + +function restoreManagedRecordVisibility(record) { + if (!record) return; + lmeState.managerHiddenObjects.delete(record.id); + lmeState.managerHiddenObjectIds.delete(record.id); + lmeState.managerHiddenMeasurementSummaryIds.delete(record.id); + lmeState.managerHiddenMeasurementSideIds.delete(record.id); + if (record.parent && !record.parent.hasLayer?.(record.layer)) { + record.parent.addLayer?.(record.layer); + } +} + +function parcelGeometriesEquivalent(candidate, target) { + const candidateType = candidate?.geometry?.type; + const targetType = target?.geometry?.type; + if (!["Polygon", "MultiPolygon"].includes(candidateType) + || !["Polygon", "MultiPolygon"].includes(targetType)) { + return false; + } + try { + const candidateArea = turf.area(candidate); + const targetArea = turf.area(target); + if (!(candidateArea > 0) || !(targetArea > 0)) return false; + const areaRatio = Math.max(candidateArea, targetArea) / Math.min(candidateArea, targetArea); + if (areaRatio > 1.03) return false; + const overlap = turf.intersect(turf.featureCollection([candidate, target])); + return Boolean(overlap) && turf.area(overlap) / Math.min(candidateArea, targetArea) >= 0.985; + } catch { + return false; + } +} + +function normalizeParcelReference(reference) { + return String(reference || "").trim().toUpperCase().replace(/\s+/g, ""); +} + function resize() { if (!lmeState.map) { return;