diff --git a/LMEMappeCatastaliApp.csproj b/LMEMappeCatastaliApp.csproj index 92a2f20..4661808 100644 --- a/LMEMappeCatastaliApp.csproj +++ b/LMEMappeCatastaliApp.csproj @@ -29,8 +29,8 @@ it.liviomerola.lmemappecatastali.pro - 1.0.20 - 120 + 1.0.21 + 121 $(ApplicationDisplayVersion) diff --git a/wwwroot/css/app.css b/wwwroot/css/app.css index 374fe12..df74280 100644 --- a/wwwroot/css/app.css +++ b/wwwroot/css/app.css @@ -1271,6 +1271,54 @@ input[type="checkbox"] { opacity: 0.72; } +.lme-geometry-toolbar a.lme-multi-selection-button.is-active { + background: #0f766e; + color: #fff; + box-shadow: inset 0 0 0 2px #99f6e4; +} + +.lme-geometry-toolbar { + position: relative; +} + +.lme-comparison-actions { + position: absolute; + right: calc(100% + 6px); + z-index: 1000; + display: grid; + min-width: 154px; + overflow: hidden; + border: 1px solid #b8c1c8; + border-radius: 4px; + background: #fff; + box-shadow: 0 2px 8px rgb(0 0 0 / 22%); +} + +.lme-geometry-toolbar .lme-comparison-actions a { + display: flex !important; + width: auto !important; + height: 36px !important; + padding: 0 10px; + justify-content: flex-start; + border: 0; + border-bottom: 1px solid #d7dde1; + color: #1f2933; + font-size: 13px !important; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; +} + +.lme-geometry-toolbar .lme-comparison-actions a:last-child { + border-bottom: 0; +} + +.lme-geometry-toolbar .lme-comparison-actions a:hover, +.lme-geometry-toolbar .lme-comparison-actions a:focus-visible { + background: #e8f4f1; + color: #075e54; +} + .leaflet-help { display: grid; gap: 14px; diff --git a/wwwroot/js/lme-map.js b/wwwroot/js/lme-map.js index 8137499..37d2ba3 100644 --- a/wwwroot/js/lme-map.js +++ b/wwwroot/js/lme-map.js @@ -37,6 +37,9 @@ const lmeState = { drawingDeleteButton: null, drawingScopeMenu: null, geometryToolsControl: null, + comparisonMenu: null, + multiSelectionEnabled: false, + multiSelectionButton: null, fiducialDownloadButton: null, ignoreMapClickUntil: 0, activeDrawHandler: null, @@ -262,6 +265,17 @@ function canAddDrawingObjects(amount = 1) { return false; } +function ensureLeafletRotateRendererCompatibility() { + const rendererPrototype = window.L?.Renderer?.prototype; + const currentOnAdd = rendererPrototype?.onAdd; + if (!currentOnAdd || currentOnAdd._lmeMapFallback) return; + const compatibleOnAdd = function (map) { + return currentOnAdd.call(this, map || this._map || lmeState.map); + }; + compatibleOnAdd._lmeMapFallback = true; + rendererPrototype.onAdd = compatibleOnAdd; +} + function init(dotNetReference) { if (dotNetReference) { lmeState.dotNetReference = dotNetReference; @@ -270,6 +284,7 @@ function init(dotNetReference) { return; } + ensureLeafletRotateRendererCompatibility(); lmeState.map = L.map("map", { zoomControl: false, attributionControl: false, @@ -334,6 +349,7 @@ function init(dotNetReference) { }); lmeState.map.createPane("drawingsMarkerPane", lmeState.map._norotatePane || lmeState.map._mapPane); lmeState.map.createPane("gpsAccuracyPane", lmeState.map._rotatePane || lmeState.map._mapPane); + lmeState.map.createPane("comparisonPane", lmeState.map._rotatePane || lmeState.map._mapPane); const managerHighlightPane = lmeState.map.createPane("managerHighlightPane", lmeState.map._rotatePane || lmeState.map._mapPane); managerHighlightPane.style.zIndex = "650"; lmeState.drawingsRenderer = L.canvas({ pane: "drawingsPane", tolerance: 14 }); @@ -421,6 +437,10 @@ function init(dotNetReference) { && !event.target?.closest?.(".leaflet-draw-edit-edit, .leaflet-draw-edit-remove")) { hideDrawingScopeMenu(); } + if (lmeState.comparisonMenu + && !event.target?.closest?.(".lme-comparison-actions, .lme-comparison-button")) { + hideComparisonMenu(); + } if (!lmeState.managerHighlightedObjectId && !lmeState.managerHighlightedParentKey) { return; } @@ -1587,7 +1607,7 @@ function setLayerOrder(order) { normalized.forEach((key, index) => { const zIndex = String(590 - index * 20); const paneNames = [panes[key]]; - if (key === "drawings") paneNames.push("drawingsMarkerPane"); + if (key === "drawings") paneNames.push("drawingsMarkerPane", "comparisonPane"); if (key === "gpsPosition") paneNames.push("gpsAccuracyPane"); paneNames.forEach(paneName => { const pane = lmeState.map.getPane(paneName); @@ -3053,7 +3073,7 @@ function addDrawingFeature(feature, select = false) { function storedFeatureStyle(feature) { const properties = feature?.properties || {}; return { - renderer: lmeState.drawingsRenderer, + ...(properties._lmeComparisonResult ? { pane: "comparisonPane" } : { renderer: lmeState.drawingsRenderer }), color: properties._lmeColor || "#f97316", fillColor: properties._lmeFillColor || "#fb923c", weight: Number(properties._lmeWeight) || 3, @@ -3238,22 +3258,116 @@ function compareSelectedGeometries() { const onlySecond = turf.difference(turf.featureCollection([second, first])); const overlap = turf.intersect(turf.featureCollection([first, second])); rememberMapChange(); - const addResult = (feature, name, color) => { + const resultLayers = []; + const addResult = (feature, name, color, role) => { if (!feature) return; - feature.properties = { name, _lmeColor: color, _lmeFillColor: color, _lmeFillOpacity: 0.38, _lmeManagerId: undefined }; - addDrawingFeature(feature); + feature.properties = { + name, + _lmeColor: color, + _lmeFillColor: color, + _lmeWeight: 3, + _lmeOpacity: 1, + _lmeFillOpacity: 0.48, + _lmeComparisonResult: true, + _lmeComparisonRole: role, + _lmeManagerId: undefined + }; + const layer = addDrawingFeature(feature); + if (layer) resultLayers.push(layer); }; - addResult(onlyFirst, `Solo ${managedObjectLabel(polygons[0], 0)}`, "#ef4444"); - addResult(onlySecond, `Solo ${managedObjectLabel(polygons[1], 1)}`, "#3b82f6"); - addResult(overlap, "Sovrapposizione", "#16a34a"); + addResult(onlyFirst, `Solo ${managedObjectLabel(polygons[0], 0)}`, "#ef4444", "only-first"); + addResult(onlySecond, `Solo ${managedObjectLabel(polygons[1], 1)}`, "#2563eb", "only-second"); + addResult(overlap, "Sovrapposizione", "#16a34a", "overlap"); const firstArea = onlyFirst ? turf.area(onlyFirst) : 0; const secondArea = onlySecond ? turf.area(onlySecond) : 0; const overlapArea = overlap ? turf.area(overlap) : 0; const message = `Confronto creato. Solo primo confine: ${formatArea(firstArea)}. Solo secondo confine: ${formatArea(secondArea)}. Sovrapposizione: ${formatArea(overlapArea)}.`; + lmeState.managerSelectedObjectIds.clear(); + clearManagerHighlight(); finishManagedGeometryChange(message); + resultLayers.forEach(layer => layer.bringToFront?.()); showGeometryResult("Confronto completato", `${message} Sulla mappa: rosso solo primo, blu solo secondo, verde sovrapposizione.`, "success"); return true; } catch (error) { showGeometryResult("Confronto non riuscito", error.message, "error"); return false; } } +function isComparisonResultLayer(layer) { + const properties = layer?.feature?.properties || {}; + if (properties._lmeComparisonResult || properties._lmeComparisonRole) return true; + const name = String(properties.name || ""); + const color = String(properties._lmeFillColor || properties._lmeColor || "").toLowerCase(); + return (name.startsWith("Solo ") && ["#ef4444", "#3b82f6", "#2563eb"].includes(color)) + || (name === "Sovrapposizione" && color === "#16a34a"); +} + +function clearComparisonResults() { + hideComparisonMenu(); + if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; } + const layers = lmeState.drawings?.getLayers?.().filter(isComparisonResultLayer) || []; + if (!layers.length) { + setInfo("Non ci sono risultati di confronto da rimuovere."); + return false; + } + rememberMapChange(); + layers.forEach(layer => { + const id = layer.feature?.properties?._lmeManagerId; + if (id) { + lmeState.managerSelectedObjectIds.delete(id); + clearManagedObjectState(id); + } + lmeState.drawings.removeLayer(layer); + }); + clearManagerHighlight(); + const message = `Rimossi ${layers.length} layer del confronto.`; + finishManagedGeometryChange(message); + return true; +} + +function showComparisonMenu(button) { + if (lmeState.comparisonMenu) { + hideComparisonMenu(); + return; + } + const menu = L.DomUtil.create("div", "lme-comparison-actions", button.parentElement); + menu.style.top = `${button.offsetTop}px`; + menu.innerHTML = `ConfrontaPulisci confronto`; + lmeState.comparisonMenu = menu; + menu.querySelectorAll("a").forEach(link => { + L.DomEvent.on(link, "click", L.DomEvent.stop) + .on(link, "mousedown", L.DomEvent.stopPropagation) + .on(link, "touchstart", L.DomEvent.stopPropagation) + .on(link, "click", () => { + const action = link.dataset.comparisonAction; + hideComparisonMenu(); + if (action === "compare") compareSelectedGeometries(); + else clearComparisonResults(); + }); + }); +} + +function hideComparisonMenu() { + lmeState.comparisonMenu?.remove(); + lmeState.comparisonMenu = null; +} + +function toggleMultiSelection() { + lmeState.multiSelectionEnabled = !lmeState.multiSelectionEnabled; + lmeState.multiSelectionButton?.classList.toggle("is-active", lmeState.multiSelectionEnabled); + lmeState.multiSelectionButton?.setAttribute("aria-pressed", String(lmeState.multiSelectionEnabled)); + setInfo(lmeState.multiSelectionEnabled + ? "Selezione multipla attiva: tocca gli oggetti per aggiungerli o rimuoverli dalla selezione." + : "Selezione multipla disattivata."); + return lmeState.multiSelectionEnabled; +} + +function refreshMultipleMapSelection(preferredRecord) { + clearManagerHighlight(); + const selectedRecords = selectedManagedRecords(); + if (!selectedRecords.length) return; + const current = selectedRecords.find(record => record.id === preferredRecord?.id) || selectedRecords.at(-1); + lmeState.managerHighlightedObjectId = current?.id || null; + lmeState.managerHighlightedCategoryKey = current?.categoryKey || null; + renderManagerHighlight(selectedRecords); +} + function showGeometryResult(title, message, tone = "success") { setInfo(`${title}: ${message}`); const icons = { success: "✓", warning: "!", error: "×" }; @@ -3275,9 +3389,10 @@ function showLeafletToolbarHelp() {
▱
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.
+
☑
Selezione multiplaAttiva il comando e tocca più oggetti sulla mappa per aggiungerli o rimuoverli dalla selezione. Disattivalo per tornare alla selezione singola. È utile prima di Confronta, Unisci, Buffer e altre operazioni su più oggetti.
+
⇄
Confronta due confiniApri il comando e scegli Confronta dopo aver selezionato esattamente due poligoni. L'app crea il solo primo in rosso, il solo secondo in blu e la sovrapposizione in verde. Usa Pulisci confronto per rimuovere soltanto questi risultati.
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.
+
?
GuidaRiapre questa descrizione. I risultati di riparazione e confronto vengono mostrati anche in una finestra; per uscire dalla visualizzazione rosso, blu e verde usa Pulisci confronto.
`); } @@ -3291,7 +3406,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: "Attiva o disattiva selezione multipla", run: toggleMultiSelection, multiSelection: true }, + { icon: "⇄", title: "Confronta o pulisci confronto", comparison: true }, { icon: "PF", title: "Scarica PF del foglio o della particella", run: downloadFiducialsForMapContext, fiducials: true }, { icon: "?", title: "Guida strumenti mappa", run: showLeafletToolbarHelp } ]; @@ -3306,7 +3422,17 @@ function addGeometryToolsControl() { button.classList.add("lme-fiducial-download"); lmeState.fiducialDownloadButton = button; } - L.DomEvent.on(button, "click", event => { L.DomEvent.stop(event); action.run(); }); + if (action.comparison) button.classList.add("lme-comparison-button"); + if (action.multiSelection) { + button.classList.add("lme-multi-selection-button"); + button.setAttribute("aria-pressed", String(lmeState.multiSelectionEnabled)); + lmeState.multiSelectionButton = button; + } + L.DomEvent.on(button, "click", event => { + L.DomEvent.stop(event); + if (action.comparison) showComparisonMenu(button); + else action.run(); + }); }); L.DomEvent.disableClickPropagation(container); return container; @@ -4667,12 +4793,17 @@ function startMeasurementDrawing(mode) { } function annotateDrawingLayer(layer, layerType) { - layer.options.pane = "drawingsPane"; - if (layer instanceof L.Path) { - layer.options.renderer = lmeState.drawingsRenderer; - } layer.feature = layer.feature || { type: "Feature", properties: {} }; layer.feature.properties = layer.feature.properties || {}; + const isComparisonResult = Boolean(layer.feature.properties._lmeComparisonResult); + layer.options.pane = isComparisonResult ? "comparisonPane" : "drawingsPane"; + if (layer instanceof L.Path) { + if (isComparisonResult) { + delete layer.options.renderer; + } else { + layer.options.renderer = lmeState.drawingsRenderer; + } + } if (layer instanceof L.Circle) { layer.feature.properties._lmeShape = "circle"; layer.feature.properties._lmeRadius = layer.getRadius(); @@ -4698,16 +4829,27 @@ function bindDrawingLayerSelection(layer) { L.DomEvent.stopPropagation(event.originalEvent); } const id = managedLayerId(layer, "drawings"); - if (event.originalEvent?.ctrlKey || event.originalEvent?.metaKey || event.originalEvent?.shiftKey) { + const multiple = lmeState.multiSelectionEnabled + || event.originalEvent?.ctrlKey + || event.originalEvent?.metaKey + || event.originalEvent?.shiftKey; + if (multiple) { if (lmeState.managerSelectedObjectIds.has(id)) lmeState.managerSelectedObjectIds.delete(id); else lmeState.managerSelectedObjectIds.add(id); } else { lmeState.managerSelectedObjectIds.clear(); lmeState.managerSelectedObjectIds.add(id); } - selectDrawingLayer(layer); refreshLayerManager(); - window.requestAnimationFrame(() => revealManagedRecordInLayerManager(id)); + if (multiple) { + refreshMultipleMapSelection({ id, categoryKey: "drawings", layer, parent: lmeState.drawings }); + setInfo(`${lmeState.managerSelectedObjectIds.size} oggetti selezionati.`); + } else { + selectDrawingLayer(layer); + } + if (lmeState.managerSelectedObjectIds.has(id)) { + window.requestAnimationFrame(() => revealManagedRecordInLayerManager(id)); + } }); } @@ -4722,14 +4864,24 @@ function bindManagedMapSelection(layer, categoryKey, parent) { L.DomEvent.stopPropagation(event.originalEvent); } const record = { id: managedLayerId(layer, categoryKey), categoryKey, layer, parent, source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente") }; - const multiple = event.originalEvent?.ctrlKey || event.originalEvent?.metaKey || event.originalEvent?.shiftKey; + const multiple = lmeState.multiSelectionEnabled + || event.originalEvent?.ctrlKey + || event.originalEvent?.metaKey + || event.originalEvent?.shiftKey; if (!multiple) lmeState.managerSelectedObjectIds.clear(); if (multiple && lmeState.managerSelectedObjectIds.has(record.id)) lmeState.managerSelectedObjectIds.delete(record.id); else lmeState.managerSelectedObjectIds.add(record.id); - highlightManagedObject(record); refreshLayerManager(); - window.requestAnimationFrame(() => revealManagedRecordInLayerManager(record.id)); - setInfo(`Selezionato dalla mappa: ${managedObjectEditableName(record) || "oggetto"}.`); + if (multiple) { + refreshMultipleMapSelection(record); + setInfo(`${lmeState.managerSelectedObjectIds.size} oggetti selezionati.`); + } else { + highlightManagedObject(record); + setInfo(`Selezionato dalla mappa: ${managedObjectEditableName(record) || "oggetto"}.`); + } + if (lmeState.managerSelectedObjectIds.has(record.id)) { + window.requestAnimationFrame(() => revealManagedRecordInLayerManager(record.id)); + } }); }