From 7933b2e86a125d6237e3d54f37361a9559e5cc84 Mon Sep 17 00:00:00 2001 From: livio Date: Mon, 21 Sep 2026 11:01:12 +0200 Subject: [PATCH] Reorganize map tools and improve KML sheet filtering --- Components/Pages/Home.razor | 276 ++++++++++++++++++++--------------- LMEMappeCatastaliApp.csproj | 4 +- README.md | 6 +- Services/AzureMapsService.cs | 2 +- wwwroot/css/app.css | 88 ++++++++++- wwwroot/js/lme-map.js | 128 ++++++++++------ 6 files changed, 331 insertions(+), 173 deletions(-) diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index c829f90..4664991 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -72,6 +72,16 @@ + @if ((ActiveTab is "map" or "search") && !LayerPanelOpen) + { +
+ Misura + + + +
+ } + @if (ActiveTab is not "map" and not "search") { @@ -302,6 +337,7 @@ @@ -331,7 +367,7 @@ private bool TrailEnabled { get; set; } private bool LayerPanelOpen { get; set; } private bool IncludeCadastralParcels { get; set; } = true; - private bool ImportParcelsOnly { get; set; } = true; + private bool IncludeKmlSheetLayer { get; set; } private bool ShowImportedMeasurements { get; set; } private bool ShowDrawingMeasurements { get; set; } = true; private bool ProjectBusy { get; set; } @@ -355,7 +391,8 @@ private string PanelTitle => ActiveTab switch { "cadastre" => "Ricerca catastale", - "projects" => "Progetti e dati", + "data" => "Dati e disegni", + "projects" => "Archivio progetti", "settings" => "Impostazioni", _ => string.Empty }; @@ -363,7 +400,8 @@ private string PanelEyebrow => ActiveTab switch { "cadastre" => "Regione, Comune, Foglio, Particella", - "projects" => "KML, KMZ, GeoJSON e POI", + "data" => "File GIS, misure e POI", + "projects" => "Salva e apri i tuoi lavori", "settings" => "Mappa, GPS e servizi ufficiali", _ => string.Empty }; @@ -751,10 +789,10 @@ private Task ClearImportedLayers() => JS.InvokeVoidAsync("lmeMap.clearImported").AsTask(); private Task AddProjectMarker() => JS.InvokeVoidAsync("lmeMap.addProjectMarker", PoiName).AsTask(); - private async Task ToggleImportParcelsOnly(ChangeEventArgs args) + private async Task ToggleKmlSheetLayer(ChangeEventArgs args) { - ImportParcelsOnly = (bool?)args.Value == true; - await JS.InvokeVoidAsync("lmeMap.setImportParcelOnly", ImportParcelsOnly); + IncludeKmlSheetLayer = (bool?)args.Value == true; + await JS.InvokeVoidAsync("lmeMap.setIncludeKmlSheetLayer", IncludeKmlSheetLayer); } private async Task ToggleImportedMeasurements(ChangeEventArgs args) diff --git a/LMEMappeCatastaliApp.csproj b/LMEMappeCatastaliApp.csproj index d36f437..892d88f 100644 --- a/LMEMappeCatastaliApp.csproj +++ b/LMEMappeCatastaliApp.csproj @@ -28,8 +28,8 @@ com.companyname.lmemappecatastaliapp - 0.2.8 - 12 + 0.2.9 + 13 None diff --git a/README.md b/README.md index 8c688ec..6d1c8b9 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata pe - Interrogazione WMS con click/tap sulla mappa. - Import di file KML, KMZ, GeoJSON e JSON. - Import GeoJSON compatibile con BOM, geometrie singole, array, FeatureCollection e coordinate Web Mercator EPSG:3857. -- Filtro KML/KMZ `solo particelle` attivo per impostazione predefinita, con esclusione di GroundOverlay e perimetri del foglio. +- Filtro KML/KMZ `solo particelle` attivo per impostazione predefinita, con separazione dei MultiPolygon ed esclusione di GroundOverlay e perimetri del foglio; il layer foglio resta importabile tramite opzione. - Inquadramento e pulizia dei layer importati. - Disegno di marker, linee, poligoni e rettangoli. -- Modalita rapide Area, Distanza e Cerchio con superficie, perimetro, raggio, circonferenza e lunghezza dei singoli lati. +- Modalita rapide Area, Distanza e Cerchio disponibili direttamente sulla mappa, con superficie, perimetro, raggio, circonferenza e lunghezza dei singoli lati. - Misure permanenti attivabili anche sui layer importati da KML, KMZ e GeoJSON. - Toolbar di disegno completa di icone, modifica, eliminazione singola e pulizia di tutti i disegni/POI. - Archivio multiprogetto nella cartella privata dell'app, con nome, selezione da elenco, sovrascrittura ed eliminazione. @@ -22,7 +22,7 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata pe - Marker posizione, cerchio accuratezza in metri, tracciamento percorso. - Controllo se la posizione GPS cade dentro un poligono importato o disegnato. - Interrogazione WFS ufficiale Agenzia delle Entrate attorno alla posizione GPS per evidenziare la particella catastale corrente quando il servizio restituisce la geometria. -- Mappa a tutto schermo con tab Catasto, Progetti e Impostazioni, adattabile agli schermi pieghevoli. +- Mappa a tutto schermo con tab separati Catasto, Dati, Progetti e Impostazioni, adattabile agli schermi pieghevoli. - Ingrandimento oltre lo zoom satellitare nativo senza tessere "Map data not yet available". - Tendine concatenate Regione, Comune, Foglio e Particella. - Ricerca di localita e indirizzi eseguita nativamente con fallback Esri e OpenStreetMap, senza richieste CORS dalla WebView. diff --git a/Services/AzureMapsService.cs b/Services/AzureMapsService.cs index 8fad5ed..84031f3 100644 --- a/Services/AzureMapsService.cs +++ b/Services/AzureMapsService.cs @@ -13,7 +13,7 @@ public sealed class AzureMapsService public AzureMapsService() { - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.8"); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.9"); _httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json"); } diff --git a/wwwroot/css/app.css b/wwwroot/css/app.css index edb206d..632c565 100644 --- a/wwwroot/css/app.css +++ b/wwwroot/css/app.css @@ -292,6 +292,43 @@ input[type="checkbox"] { color: var(--ink); } +.map-measure-tools { + position: absolute; + top: 72px; + right: 12px; + z-index: 860; + display: grid; + grid-template-columns: auto repeat(3, minmax(76px, auto)); + align-items: center; + gap: 6px; + padding: 7px; + border: 1px solid rgba(255, 255, 255, 0.7); + border-radius: 8px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 3px 14px rgba(0, 0, 0, 0.2); + transition: top 140ms ease; +} + +.map-measure-tools > span { + padding: 0 4px; + color: var(--muted); + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} + +.map-measure-tools button { + min-height: 38px; + padding: 0 10px; + background: #fff3e8; + color: #8a3a08; + border: 1px solid #e4b58f; +} + +.map-measure-tools.search-open { + top: 140px; +} + .tool-sheet { position: absolute; top: 0; @@ -358,6 +395,29 @@ h2 { padding: 15px 16px 24px; } +.panel-section { + display: grid; + gap: 12px; + padding: 0 0 16px; + border-bottom: 1px solid var(--line); +} + +.panel-section:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.panel-section h2 { + margin-top: 0; +} + +.field-help { + margin-top: -7px; + color: var(--muted); + font-size: 10px; + line-height: 1.35; +} + label { display: grid; gap: 5px; @@ -637,7 +697,7 @@ label { left: 0; z-index: 1200; display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); height: var(--nav-height); padding-bottom: env(safe-area-inset-bottom); border-top: 1px solid #bcc7cd; @@ -695,6 +755,16 @@ footer { display: none; } +@media (min-width: 701px) and (max-width: 900px) { + .map-measure-tools { + top: 126px; + } + + .map-measure-tools.search-open { + top: 194px; + } +} + @media (max-width: 700px) { .map-toolbar { top: 8px; @@ -738,6 +808,22 @@ footer { top: 136px; } + .map-measure-tools, + .map-measure-tools.search-open { + top: 126px; + right: 8px; + left: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .map-measure-tools.search-open { + top: 194px; + } + + .map-measure-tools > span { + display: none; + } + .gps-main, .gps-center { padding: 0 8px; diff --git a/wwwroot/js/lme-map.js b/wwwroot/js/lme-map.js index 2373ae9..3f02d0f 100644 --- a/wwwroot/js/lme-map.js +++ b/wwwroot/js/lme-map.js @@ -13,7 +13,7 @@ const lmeState = { drawControl: null, activeDrawHandler: null, drawEventsBound: false, - importParcelOnly: true, + includeKmlSheetLayer: false, showImportedMeasurements: false, showDrawingMeasurements: true, measurementRefreshTimer: null, @@ -47,7 +47,7 @@ window.lmeMap = { clearMapElements, fitImported, clearImported, - setImportParcelOnly, + setIncludeKmlSheetLayer, setImportedMeasurementsVisible, setDrawingMeasurementsVisible, addProjectMarker, @@ -447,7 +447,7 @@ function bindFileImport() { refreshImportedMeasurements(); const featureCount = geojson.features.length; const ignored = result.ignoredCount - ? `; esclusi ${result.ignoredCount} elementi non particella` + ? `; esclusi ${result.ignoredCount} elementi del foglio o non particella` : ""; const reprojected = result.reprojected ? "; coordinate Web Mercator convertite" : ""; setInfo(`Caricato ${file.name}: ${featureCount} elementi${ignored}${reprojected}.`); @@ -652,13 +652,14 @@ function normalizeKmlFiles(files) { throw new Error("KML senza geometrie utilizzabili"); } - const selected = lmeState.importParcelOnly ? filterKmlParcelFeatures(features) : features; + const polygonParts = splitKmlPolygonFeatures(features); + const selected = lmeState.includeKmlSheetLayer ? features : filterKmlParcelFeatures(polygonParts); if (!selected.length) { - throw new Error("nessuna particella trovata; disattiva 'solo particelle' per importare tutti gli elementi"); + throw new Error("nessuna particella trovata; attiva 'includi layer del foglio' per importare tutti gli elementi"); } return { geojson: { type: "FeatureCollection", features: selected }, - ignoredCount: features.length - selected.length, + ignoredCount: lmeState.includeKmlSheetLayer ? 0 : Math.max(0, polygonParts.length - selected.length), reprojected: false }; } @@ -690,10 +691,7 @@ function kmlTextToGeoJson(text, sourceName) { } function filterKmlParcelFeatures(features) { - const polygons = features - .map(feature => ({ ...feature, geometry: polygonOnlyGeometry(feature.geometry) })) - .filter(feature => feature.geometry); - const filtered = polygons.filter(feature => { + const filtered = features.filter(feature => { const properties = feature.properties || {}; if (properties["@geometry-type"] === "groundoverlay") { return false; @@ -706,45 +704,81 @@ function filterKmlParcelFeatures(features) { const folder = String(properties._lmeFolder || "").toLowerCase(); const isParcel = /particell|mappal|parcel/.test(`${identity} ${folder}`) || /^[a-z]?\s*\d+[a-z]?(?:[\/. -]\d+[a-z]?)?$/i.test(name); - const isSheet = /\bfoglio\b|\bsheet\b|quadro[ _-]?d[' ]?unione|perimetro[ _-]?foglio/.test(identity); + const isSheet = /\bfoglio\b|\bsheet\b|quadro[ _-]?d[' ]?unione|(?:perimetro|bordo|limite|contorno)[ _-]?(?:del[ _-]?)?foglio|sheet[ _-]?boundary/.test(identity); return isParcel || !isSheet; }); - return removeLikelySheetOutline(filtered); + return removeLikelySheetOutlines(filtered); } -function polygonOnlyGeometry(geometry) { - if (!geometry) { - return null; - } - if (geometry.type === "Polygon" || geometry.type === "MultiPolygon") { - return geometry; - } - if (geometry.type === "GeometryCollection") { - const geometries = (geometry.geometries || []).filter(item => - item.type === "Polygon" || item.type === "MultiPolygon"); - return geometries.length ? { type: "GeometryCollection", geometries } : null; - } - return null; +function splitKmlPolygonFeatures(features) { + const result = []; + const addGeometry = (feature, geometry, partIndex) => { + if (!geometry) { + return; + } + if (geometry.type === "Polygon") { + result.push({ + ...feature, + properties: { ...(feature.properties || {}), _lmePart: partIndex }, + geometry + }); + return; + } + if (geometry.type === "MultiPolygon") { + (geometry.coordinates || []).forEach((coordinates, index) => addGeometry( + feature, + { type: "Polygon", coordinates }, + `${partIndex}.${index}`)); + return; + } + if (geometry.type === "GeometryCollection") { + (geometry.geometries || []).forEach((item, index) => addGeometry(feature, item, `${partIndex}.${index}`)); + } + }; + features.forEach((feature, index) => addGeometry(feature, feature.geometry, index)); + return result; } -function removeLikelySheetOutline(features) { - if (features.length < 3) { - return features; +function removeLikelySheetOutlines(features) { + let remaining = [...features]; + while (remaining.length >= 2) { + const candidates = remaining + .map(feature => ({ feature, bounds: geoJsonBounds(feature.geometry) })) + .filter(item => item.bounds); + if (candidates.length < 2) { + break; + } + + const largest = candidates.reduce((current, item) => + boundsArea(item.bounds) > boundsArea(current.bounds) ? item : current); + if (isStrongParcelFeature(largest.feature)) { + break; + } + const others = candidates.filter(item => item !== largest); + const comparisonAreas = others.map(item => boundsArea(item.bounds)).sort((a, b) => a - b); + const referenceArea = comparisonAreas[Math.floor(comparisonAreas.length / 2)]; + const contained = others.filter(item => boundsContains(largest.bounds, boundsCenter(item.bounds))).length; + const minimumContained = candidates.length === 2 ? 1 : Math.ceil(others.length * 0.6); + const areaFactor = 2.5; + const looksLikeSheet = referenceArea > 0 + && boundsArea(largest.bounds) > referenceArea * areaFactor + && contained >= minimumContained; + if (!looksLikeSheet) { + break; + } + remaining = remaining.filter(feature => feature !== largest.feature); } - const candidates = features.map(feature => ({ feature, bounds: geoJsonBounds(feature.geometry) })); - const valid = candidates.filter(item => item.bounds); - if (valid.length < 3) { - return features; - } - const areas = valid.map(item => boundsArea(item.bounds)).sort((a, b) => a - b); - const median = areas[Math.floor(areas.length / 2)]; - const largest = valid.reduce((current, item) => - boundsArea(item.bounds) > boundsArea(current.bounds) ? item : current); - const others = valid.filter(item => item !== largest); - const contained = others.filter(item => boundsContains(largest.bounds, boundsCenter(item.bounds))).length; - const looksLikeSheet = boundsArea(largest.bounds) > median * 8 - && contained >= Math.ceil(others.length * 0.75); - return looksLikeSheet ? features.filter(feature => feature !== largest.feature) : features; + return remaining; +} + +function isStrongParcelFeature(feature) { + const properties = feature?.properties || {}; + const name = String(properties.name || properties.Name || properties.LABEL || "").trim(); + const context = [name, properties.layer, properties.Layer, properties.type, properties.Tipo, properties._lmeFolder] + .filter(Boolean) + .join(" "); + return /particell|mappal|parcel/i.test(context) + || /^[a-z]?\s*\d+[a-z]?(?:[\/. -]\d+[a-z]?)?$/i.test(name); } function geoJsonBounds(geometry) { @@ -774,11 +808,11 @@ function boundsContains(bounds, point) { && point[1] >= bounds.south && point[1] <= bounds.north; } -function setImportParcelOnly(enabled) { - lmeState.importParcelOnly = Boolean(enabled); - setInfo(lmeState.importParcelOnly - ? "Importazione KML/KMZ: verranno caricate solo le particelle." - : "Importazione KML/KMZ: verranno caricati tutti gli elementi."); +function setIncludeKmlSheetLayer(enabled) { + lmeState.includeKmlSheetLayer = Boolean(enabled); + setInfo(lmeState.includeKmlSheetLayer + ? "Importazione KML/KMZ: verranno inclusi anche foglio e altri elementi." + : "Importazione KML/KMZ: verranno caricate solo le particelle riconosciute."); } function setImportedMeasurementsVisible(enabled) {