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.
PFScarica 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));
+ }
});
}