Files
LMEMappeCatastaliProApp/wwwroot/js/lme-map.js
T

7183 lines
315 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const lmeState = {
map: null,
baseLayers: {},
currentBase: null,
currentBaseName: "satellite",
layerOrder: [
"gpsPosition",
"gpsTrail",
"fiducials",
"drawings",
"gpsParcels",
"selectedParcels",
"importedSheets",
"imported",
"compass",
"cadastre"
],
cadastre: null,
cadastreZoning: null,
cadastreLayers: ["CP.CadastralParcel", "codice_plla", "CP.CadastralZoning", "fabbricati"],
cadastreOpacity: 0.75,
navigationSheetLayer: null,
navigationSheetFeature: null,
navigationSheetLabel: null,
navigationSheetRefreshTimer: null,
navigationSheetRequestId: 0,
imported: null,
importedVisible: true,
importedSheets: null,
importedSheetsVisible: false,
importedMeasurements: null,
drawings: null,
drawingsRenderer: null,
drawingSelection: null,
drawingMeasurements: null,
drawControl: null,
drawingDeleteButton: null,
drawingScopeMenu: null,
geometryToolsControl: null,
comparisonMenu: null,
selectionMenu: null,
selectionMode: "single",
selectionModeButton: null,
fiducialDownloadButton: null,
ignoreMapClickUntil: 0,
activeDrawHandler: null,
drawEventsBound: false,
includeKmlSheetLayer: false,
showImportedMeasurements: false,
showDrawingMeasurements: true,
measurementRefreshTimer: null,
gpsMarker: null,
gpsAccuracy: null,
gpsPositionVisible: true,
hasLiveGpsFix: false,
lastAccuracy: 0,
gpsTrail: null,
selectedParcelsLayer: null,
selectedParcelsVisible: true,
fiducials: null,
fiducialsVisible: true,
fiducialColor: "#dc2626",
gpsParcelLayer: null,
gpsTrailEnabled: false,
gpsTrailPoints: [],
gpsTrailStartedAt: null,
gpsWatchId: null,
lastPosition: null,
compassLayer: null,
compassElement: null,
compassVisible: false,
compassMode: "fixed",
compassHeading: 0,
compassDisplayRotation: 0,
compassTargetRotation: 0,
compassHeadingInitialized: false,
compassAnimationFrame: null,
compassAnimationTime: 0,
compassPosition: null,
compassDragCleanup: null,
compassResizeHandler: null,
infoTimeoutSeconds: 3,
infoTimer: null,
fileImportBound: false,
resizeBound: false,
googleMapsLoading: null,
azureMapsKey: null,
azureAttribution: null,
managerHighlight: null,
managerHighlightedObjectId: null,
managerHighlightedCategoryKey: null,
managerHighlightedParentKey: null,
managerHighlightedParentRecords: null,
managerHoveredParentKey: null,
managerHoveredParentRecords: null,
managerDirectHighlightStyles: new Map(),
managerOutsideSelectionBound: false,
managerHiddenObjects: new Map(),
managerHiddenObjectIds: new Set(),
managerHiddenMeasurementSummaryIds: new Set(),
managerHiddenMeasurementSideIds: new Set(),
managerHiddenCategories: new Set(),
managerRemovedCategories: new Set(),
managerObjectOrder: {},
managerCategoryNames: {},
managerExpandedRows: new Set(),
managerSelectedObjectIds: new Set(),
managerSearchText: "",
permanentLabels: null,
undoStack: [],
redoStack: [],
historyBusy: false,
snapEnabled: true,
snapDistancePixels: 14,
autoSaveEnabled: true,
autoFitSelectedObject: false,
gpsProximityEnabled: true,
gpsBoundaryAlertEnabled: false,
lastBoundaryState: null,
trash: [],
auditLog: [],
isDirty: false,
readOnly: false,
changeNotifyTimer: null,
offlineDbPromise: null,
offlineTileCount: 0,
dotNetReference: null,
culture: "it-IT",
measurementSystem: "metric",
entitlements: {
advancedGeometry: false,
fiducialPoints: false,
multipleNotes: false,
maxDrawingObjects: 15,
drawingLimitMessage: "Hai raggiunto il limite di 15 oggetti del piano Base."
},
labels: {
measureArea: "Misura area",
measureDistance: "Misura distanza",
measureCircle: "Misura cerchio",
drawRectangle: "Disegna rettangolo",
addPoi: "Aggiungi POI",
proRequired: "Funzione inclusa in LME Pro."
}
};
// AdE publishes the cadastral WMS in ETRS89. WMS 1.1.1 keeps the longitude,
// latitude BBOX order expected by Leaflet while sending the required CRS code.
const cadastreCrs = L.extend({}, L.CRS.EPSG4326, { code: "EPSG:4258" });
window.lmeMap = {
init,
showSearchResult,
showInitialPosition,
displayOfficialParcel,
exportCadastreView,
closeCadastrePreview,
clearSelectedParcels,
setSelectedParcelsVisible,
setFiducialPoints,
setFiducialPointsVisible,
setFiducialColor,
fitFiducialPoints,
clearMapElements,
fitImported,
clearImported,
setIncludeKmlSheetLayer,
setImportedLayerVisible,
setImportedSheetLayerVisible,
setLayerOrder,
refreshLayerManager,
clearLayerManagerSelection: clearManagerHighlight,
setImportedMeasurementsVisible,
setDrawingMeasurementsVisible,
addProjectMarker,
startMeasurementDrawing,
setDrawToolsVisible,
clearDrawings,
undoMapChange,
redoMapChange,
getHistoryState,
markProjectSaved,
hasUnsavedChanges: () => lmeState.isDirty,
confirmDiscardChanges,
setProjectReadOnly,
restoreAutosave,
compareProjectSnapshots,
promptProjectName,
setAdvancedOptions,
createMapExportGeoJson,
createProjectSnapshot,
loadProjectSnapshot,
getLegacyProjectSnapshot,
clearLegacyProjectSnapshot,
setBaseLayer,
configureGoogleMaps,
configureAzureMaps,
clearAzureMaps,
setCadastreOpacity,
toggleCadastreLayer,
startGps,
stopGps,
centerOnGps,
toggleTrail,
clearGpsTrail,
getGpsTrailStats,
setNativeGpsPosition,
setGpsPositionVisible,
setCompassOptions,
setCompassHeading,
setGpsError,
saveGpsMeasurementPoint,
restoreLastTrashItem,
emptyTrash,
showObjectTable,
showAuditLog,
showCoordinateInspector,
validateAndRepairSelected,
createHoleFromSelected,
compareSelectedGeometries,
downloadOfflineArea,
clearOfflineTiles,
getOfflineTileStats,
resize,
setLocalization,
setEntitlements,
importExternalGisFile,
showInfo: setInfo,
hideInfo,
setInfoTimeout
};
function setLocalization(options = {}) {
lmeState.culture = options.culture || lmeState.culture || "it-IT";
lmeState.measurementSystem = options.measurementSystem === "imperial" ? "imperial" : "metric";
lmeState.labels = { ...lmeState.labels, ...(options.labels || {}) };
configureDrawLabels();
if (lmeState.drawControl) {
const wasVisible = true;
setDrawToolsVisible(false);
if (wasVisible) {
setDrawToolsVisible(true);
}
}
refreshImportedMeasurements();
refreshDrawingMeasurements();
refreshLayerManager();
}
function setEntitlements(options = {}) {
lmeState.entitlements = { ...lmeState.entitlements, ...options };
updateFiducialDownloadButton();
}
function requireProFeature(feature) {
if (lmeState.entitlements?.[feature]) return true;
setInfo(lmeState.labels.proRequired || "Funzione inclusa in LME Pro.");
return false;
}
function drawingObjectCount() {
return lmeState.drawings ? managedRecordsForCategory("drawings").length : 0;
}
function canAddDrawingObjects(amount = 1) {
const configuredMaximum = lmeState.entitlements?.maxDrawingObjects;
if (configuredMaximum === null || configuredMaximum === undefined || configuredMaximum === "") return true;
const maximum = Number(configuredMaximum);
if (!Number.isFinite(maximum) || maximum < 0) return true;
if (drawingObjectCount() + Math.max(0, Number(amount) || 0) <= maximum) return true;
setInfo(lmeState.entitlements?.drawingLimitMessage || `Limite di ${maximum} oggetti raggiunto.`);
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;
}
if (lmeState.map) {
return;
}
ensureLeafletRotateRendererCompatibility();
lmeState.map = L.map("map", {
zoomControl: false,
attributionControl: false,
rotate: true,
bearing: 0,
dragRotate: false,
touchRotate: false,
shiftKeyRotate: false,
rotateControl: false
}).setView([41.9028, 12.4964], 17);
L.control.zoom({ position: "bottomright" }).addTo(lmeState.map);
lmeState.baseLayers.osm = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxNativeZoom: 19,
maxZoom: 22,
attribution: "&copy; OpenStreetMap contributors"
});
lmeState.baseLayers.satellite = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", {
maxNativeZoom: 19,
maxZoom: 22,
attribution: "Tiles &copy; Esri"
});
lmeState.baseLayers.topographic = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", {
maxNativeZoom: 19,
maxZoom: 22,
attribution: "Tiles &copy; Esri"
});
lmeState.baseLayers["streets-esri"] = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}", {
maxNativeZoom: 19,
maxZoom: 22,
attribution: "Tiles &copy; Esri"
});
lmeState.baseLayers.terrain = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", {
maxNativeZoom: 17,
maxZoom: 22,
attribution: "Map data &copy; OpenStreetMap contributors, SRTM | Map style &copy; OpenTopoMap"
});
lmeState.baseLayers.carto = L.tileLayer("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", {
subdomains: "abcd",
maxNativeZoom: 20,
maxZoom: 22,
attribution: "&copy; OpenStreetMap contributors &copy; CARTO"
});
lmeState.baseLayers.offline = createOfflineTileLayer();
lmeState.currentBase = lmeState.baseLayers.satellite.addTo(lmeState.map);
lmeState.compassLayer = createCompassLayer();
restoreLayerOrder();
Object.values(managedLayerPanes()).forEach(paneName => {
if (!lmeState.map.getPane(paneName)) {
const noRotation = paneName === "gpsPositionPane";
lmeState.map.createPane(paneName, noRotation
? (lmeState.map._norotatePane || lmeState.map._mapPane)
: (lmeState.map._rotatePane || lmeState.map._mapPane));
}
});
lmeState.map.createPane("drawingsMarkerPane", lmeState.map._norotatePane || lmeState.map._mapPane);
lmeState.map.createPane("gpsAccuracyPane", lmeState.map._rotatePane || lmeState.map._mapPane);
const comparisonPane = lmeState.map.createPane("comparisonPane", lmeState.map._rotatePane || lmeState.map._mapPane);
comparisonPane.style.zIndex = "680";
const managerHighlightPane = lmeState.map.createPane("managerHighlightPane", lmeState.map._rotatePane || lmeState.map._mapPane);
managerHighlightPane.style.zIndex = "650";
lmeState.drawingsRenderer = L.canvas({ pane: "drawingsPane", tolerance: 14 });
setLayerOrder(lmeState.layerOrder);
lmeState.imported = L.featureGroup().addTo(lmeState.map);
lmeState.importedSheets = L.featureGroup();
lmeState.drawings = L.featureGroup().addTo(lmeState.map);
lmeState.drawingSelection = L.featureGroup();
lmeState.importedMeasurements = L.layerGroup().addTo(lmeState.map);
lmeState.drawingMeasurements = L.layerGroup().addTo(lmeState.map);
lmeState.selectedParcelsLayer = L.featureGroup().addTo(lmeState.map);
lmeState.fiducials = L.featureGroup().addTo(lmeState.map);
lmeState.gpsParcelLayer = L.featureGroup().addTo(lmeState.map);
const navigationSheetPane = lmeState.map.createPane("navigationSheetPane", lmeState.map._rotatePane || lmeState.map._mapPane);
navigationSheetPane.style.zIndex = "465";
navigationSheetPane.style.pointerEvents = "none";
const cadastreZoningPane = lmeState.map.createPane("cadastreZoningPane", lmeState.map._rotatePane || lmeState.map._mapPane);
cadastreZoningPane.style.zIndex = "455";
cadastreZoningPane.style.pointerEvents = "none";
lmeState.navigationSheetLayer = L.layerGroup().addTo(lmeState.map);
lmeState.gpsTrail = L.polyline([], {
pane: "gpsTrailPane",
color: "#f97316",
weight: 4,
opacity: 0.85
}).addTo(lmeState.map);
lmeState.managerHighlight = L.layerGroup().addTo(lmeState.map);
lmeState.permanentLabels = L.layerGroup().addTo(lmeState.map);
refreshCadastreLayer();
bindFileImport();
bindDrawingEvents();
addGeometryToolsControl();
if (!lmeState.resizeBound) {
window.addEventListener("resize", resize);
window.addEventListener("orientationchange", resize);
lmeState.resizeBound = true;
}
lmeState.map.on("click", async event => {
if (event.originalEvent?._lmeDrawingSelected || Date.now() < lmeState.ignoreMapClickUntil) {
return;
}
hideDrawingScopeMenu();
if (!selectionHasObjects()) clearManagerHighlight();
const info = await queryFeatureInfo(event.latlng);
if (info) {
L.popup().setLatLng(event.latlng).setContent(info).openOn(lmeState.map);
}
});
lmeState.map.on("moveend", () => {
void updateAzureAttribution();
scheduleMeasurementRefresh();
scheduleNavigationSheetRefresh();
});
document.getElementById("closeWmsPreview")?.addEventListener("click", closeCadastrePreview);
document.getElementById("wmsPreview")?.addEventListener("click", event => {
if (event.target?.id === "wmsPreview") {
closeCadastrePreview();
}
});
document.addEventListener("keydown", event => {
if (event.key === "Escape") {
closeCadastrePreview();
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === "z") {
event.preventDefault();
undoMapChange();
} else if ((event.ctrlKey || event.metaKey) && (event.key.toLowerCase() === "y" || (event.shiftKey && event.key.toLowerCase() === "z"))) {
event.preventDefault();
redoMapChange();
}
});
window.addEventListener("beforeunload", event => {
if (!lmeState.isDirty) return;
event.preventDefault();
event.returnValue = "";
});
if (!lmeState.managerOutsideSelectionBound) {
document.addEventListener("pointerdown", event => {
if (lmeState.drawingScopeMenu
&& !event.target?.closest?.(".lme-drawing-scope-actions")
&& !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.selectionMenu
&& !event.target?.closest?.(".lme-selection-actions, .lme-selection-mode-button")) {
hideSelectionMenu();
}
if (!lmeState.managerHighlightedObjectId && !lmeState.managerHighlightedParentKey) {
return;
}
if (event.target?.closest?.(".leaflet-draw")) {
return;
}
const selectedObject = event.target?.closest?.(".layer-manager-object");
const selectedParent = event.target?.closest?.("[data-highlight-key]");
if (selectedObject?.dataset.objectId === lmeState.managerHighlightedObjectId
|| selectedParent?.dataset.highlightKey === lmeState.managerHighlightedParentKey) {
return;
}
if (selectionHasObjects()) {
renderSelectedManagerHighlight();
return;
}
clearManagerHighlight();
});
lmeState.managerOutsideSelectionBound = true;
}
setInfo("Mappa pronta: satellite + catasto AdE");
}
function createCompassLayer() {
return new (L.Layer.extend({
onAdd(map) {
const element = L.DomUtil.create("div", "map-compass");
element.setAttribute("role", "img");
element.setAttribute("aria-label", "Bussola: nord");
element.setAttribute("title", "Trascina per spostare la bussola");
element.innerHTML = `<span class="map-compass-rose" aria-hidden="true">
<span class="map-compass-direction is-north" style="--direction-angle:0deg;--label-counter:0deg"><span>N</span></span>
<span class="map-compass-direction is-intermediate" style="--direction-angle:45deg;--label-counter:-45deg"><span>NE</span></span>
<span class="map-compass-direction" style="--direction-angle:90deg;--label-counter:-90deg"><span>E</span></span>
<span class="map-compass-direction is-intermediate" style="--direction-angle:135deg;--label-counter:-135deg"><span>SE</span></span>
<span class="map-compass-direction" style="--direction-angle:180deg;--label-counter:-180deg"><span>S</span></span>
<span class="map-compass-direction is-intermediate" style="--direction-angle:225deg;--label-counter:-225deg"><span>SW</span></span>
<span class="map-compass-direction" style="--direction-angle:270deg;--label-counter:-270deg"><span>W</span></span>
<span class="map-compass-direction is-intermediate" style="--direction-angle:315deg;--label-counter:-315deg"><span>NW</span></span>
<span class="map-compass-needle"></span>
</span><span class="map-compass-center" aria-hidden="true"></span>`;
L.DomEvent.disableClickPropagation(element);
L.DomEvent.disableScrollPropagation(element);
map.getContainer().appendChild(element);
lmeState.compassElement = element;
restoreCompassPosition();
bindCompassDragging(element);
lmeState.compassResizeHandler = () => applyCompassPosition();
map.on("resize", lmeState.compassResizeHandler);
requestAnimationFrame(() => applyCompassPosition());
updateCompassDisplay();
},
onRemove(map) {
stopCompassAnimation();
lmeState.compassDragCleanup?.();
lmeState.compassDragCleanup = null;
if (lmeState.compassResizeHandler) {
map.off("resize", lmeState.compassResizeHandler);
lmeState.compassResizeHandler = null;
}
lmeState.compassElement?.remove();
lmeState.compassElement = null;
}
}))();
}
function restoreCompassPosition() {
if (lmeState.compassPosition) return;
try {
const saved = JSON.parse(localStorage.getItem("lmeCompassPosition") || "null");
const x = Number(saved?.x);
const y = Number(saved?.y);
if (Number.isFinite(x) && Number.isFinite(y)) {
lmeState.compassPosition = {
x: Math.max(0, Math.min(1, x)),
y: Math.max(0, Math.min(1, y))
};
}
} catch {
}
}
function applyCompassPosition() {
const element = lmeState.compassElement;
const container = lmeState.map?.getContainer();
if (!element || !container) return;
const width = container.clientWidth;
const height = container.clientHeight;
const elementWidth = element.offsetWidth;
const elementHeight = element.offsetHeight;
if (!width || !height || !elementWidth || !elementHeight) return;
const halfWidth = elementWidth / 2;
const halfHeight = elementHeight / 2;
const position = lmeState.compassPosition || {
x: (12 + halfWidth) / width,
y: 0.5
};
const centerX = Math.max(halfWidth + 4, Math.min(width - halfWidth - 4, position.x * width));
const centerY = Math.max(halfHeight + 4, Math.min(height - halfHeight - 4, position.y * height));
element.style.left = `${centerX - halfWidth}px`;
element.style.top = `${centerY - halfHeight}px`;
element.style.transform = "none";
}
function bindCompassDragging(element) {
let pointerId = null;
let offsetX = 0;
let offsetY = 0;
const move = event => {
if (event.pointerId !== pointerId) return;
event.preventDefault();
event.stopPropagation();
const container = lmeState.map?.getContainer();
if (!container) return;
const containerRect = container.getBoundingClientRect();
const elementWidth = element.offsetWidth;
const elementHeight = element.offsetHeight;
const left = event.clientX - containerRect.left - offsetX;
const top = event.clientY - containerRect.top - offsetY;
const centerX = Math.max(elementWidth / 2 + 4,
Math.min(containerRect.width - elementWidth / 2 - 4, left + elementWidth / 2));
const centerY = Math.max(elementHeight / 2 + 4,
Math.min(containerRect.height - elementHeight / 2 - 4, top + elementHeight / 2));
lmeState.compassPosition = {
x: centerX / containerRect.width,
y: centerY / containerRect.height
};
applyCompassPosition();
};
const finish = event => {
if (event.pointerId !== pointerId) return;
event.preventDefault();
event.stopPropagation();
element.classList.remove("is-dragging");
if (element.hasPointerCapture?.(pointerId)) {
element.releasePointerCapture(pointerId);
}
pointerId = null;
try {
localStorage.setItem("lmeCompassPosition", JSON.stringify(lmeState.compassPosition));
} catch {
}
};
const start = event => {
if (event.pointerType === "mouse" && event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
const rect = element.getBoundingClientRect();
pointerId = event.pointerId;
offsetX = event.clientX - rect.left;
offsetY = event.clientY - rect.top;
element.classList.add("is-dragging");
element.setPointerCapture?.(pointerId);
};
element.addEventListener("pointerdown", start);
element.addEventListener("pointermove", move);
element.addEventListener("pointerup", finish);
element.addEventListener("pointercancel", finish);
lmeState.compassDragCleanup = () => {
element.removeEventListener("pointerdown", start);
element.removeEventListener("pointermove", move);
element.removeEventListener("pointerup", finish);
element.removeEventListener("pointercancel", finish);
};
}
function setCompassOptions(visible, mode = "fixed") {
lmeState.compassVisible = Boolean(visible);
lmeState.compassMode = mode === "heading" ? "heading" : "fixed";
if (!lmeState.map || !lmeState.compassLayer) return;
if (lmeState.compassVisible) {
lmeState.managerHiddenCategories.delete("compass");
if (!lmeState.map.hasLayer(lmeState.compassLayer)) {
lmeState.compassLayer.addTo(lmeState.map);
}
} else {
stopCompassAnimation();
lmeState.managerHiddenCategories.add("compass");
if (lmeState.map.hasLayer(lmeState.compassLayer)) {
lmeState.map.removeLayer(lmeState.compassLayer);
}
}
if (!lmeState.compassVisible || lmeState.compassMode === "fixed") {
lmeState.map.setHeading?.(null);
lmeState.map.setBearing?.(0);
} else if (Number.isFinite(lmeState.compassHeading)) {
lmeState.map.setHeading?.(lmeState.compassHeading, { ease: 0.2, deadzone: 1 });
}
updateCompassDisplay();
refreshLayerManager();
}
function setCompassHeading(heading) {
const parsed = Number(heading);
if (!Number.isFinite(parsed)) return;
const normalizedHeading = ((parsed % 360) + 360) % 360;
const requestedRotation = -normalizedHeading;
lmeState.compassHeading = normalizedHeading;
if (!lmeState.compassHeadingInitialized) {
lmeState.compassHeadingInitialized = true;
lmeState.compassDisplayRotation = requestedRotation;
lmeState.compassTargetRotation = requestedRotation;
updateCompassDisplay();
} else {
const delta = shortestAngleDelta(lmeState.compassTargetRotation, requestedRotation);
if (Math.abs(delta) >= 0.15) {
lmeState.compassTargetRotation += delta;
startCompassAnimation();
}
}
if (lmeState.compassVisible && lmeState.compassMode === "heading") {
lmeState.map?.setHeading?.(lmeState.compassHeading, { ease: 0.2, deadzone: 1 });
}
}
function shortestAngleDelta(from, to) {
return ((((to - from) + 180) % 360) + 360) % 360 - 180;
}
function startCompassAnimation() {
if (lmeState.compassAnimationFrame !== null) return;
lmeState.compassAnimationTime = 0;
lmeState.compassAnimationFrame = requestAnimationFrame(animateCompass);
}
function animateCompass(timestamp) {
lmeState.compassAnimationFrame = null;
const elapsed = lmeState.compassAnimationTime
? Math.min(50, Math.max(1, timestamp - lmeState.compassAnimationTime))
: 16;
lmeState.compassAnimationTime = timestamp;
const difference = lmeState.compassTargetRotation - lmeState.compassDisplayRotation;
const smoothing = 1 - Math.exp(-elapsed / 85);
lmeState.compassDisplayRotation += difference * smoothing;
if (Math.abs(difference) < 0.05) {
lmeState.compassDisplayRotation = lmeState.compassTargetRotation;
lmeState.compassAnimationTime = 0;
} else {
lmeState.compassAnimationFrame = requestAnimationFrame(animateCompass);
}
updateCompassDisplay();
}
function stopCompassAnimation() {
if (lmeState.compassAnimationFrame !== null) {
cancelAnimationFrame(lmeState.compassAnimationFrame);
lmeState.compassAnimationFrame = null;
}
lmeState.compassAnimationTime = 0;
}
function updateCompassDisplay() {
const element = lmeState.compassElement;
if (!element) return;
element.classList.toggle("is-fixed", lmeState.compassMode === "fixed");
element.style.setProperty("--compass-rotation", `${lmeState.compassDisplayRotation}deg`);
element.setAttribute("aria-label", lmeState.compassMode === "fixed"
? `Bussola: mappa con nord fisso, orientamento ${Math.round(lmeState.compassHeading)} gradi`
: `Bussola: nord, orientamento ${Math.round(lmeState.compassHeading)} gradi`);
}
function showSearchResult(latitude, longitude, label) {
const lat = Number(latitude);
const lon = Number(longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
setInfo("La ricerca ha restituito coordinate non valide.");
return;
}
lmeState.map.setView([lat, lon], 18);
setInfo(label || `${lat.toFixed(6)}, ${lon.toFixed(6)}`);
}
function showInitialPosition(latitudeValue, longitudeValue, approximate, label) {
const latitude = Number(latitudeValue);
const longitude = Number(longitudeValue);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return;
}
const latlng = [latitude, longitude];
if (!approximate) {
lmeState.lastPosition = latlng;
}
lmeState.map.setView(latlng, approximate ? 13 : 19);
setText("gpsState", approximate
? "Posizione approssimativa dalla connessione - GPS non attivo"
: "Posizione iniziale acquisita - GPS non attivo");
setInfo(approximate
? `Mappa centrata in modo approssimativo su ${label || "questa zona"}.`
: "Mappa centrata sulla posizione attuale. GPS continuo non attivo.");
if (!approximate) {
updateGpsInsideState();
}
}
function displayOfficialParcel(xmlText, latitude, longitude, parcel, label, shouldFit) {
const lat = Number(latitude);
const lon = Number(longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
setInfo("Coordinate della particella non valide.");
return "invalid";
}
try {
const geojson = gmlParcelsToGeoJson(xmlText);
const normalizedParcel = normalizeParcel(parcel);
const featureByNumber = normalizedParcel
? (geojson.features || []).find(item => {
const props = item.properties || {};
return String(props.LABEL || "").trim().toUpperCase() === normalizedParcel
|| String(props.NATIONALCADASTRALREFERENCE || "").toUpperCase().endsWith(`.${normalizedParcel}`);
})
: null;
const feature = featureByNumber
|| (geojson.features || []).find(item => geometryContainsPoint(item.geometry, [lon, lat]))
|| geojson.features?.[0];
if (!feature) {
if (shouldFit) {
lmeState.map.setView([lat, lon], 20);
}
setInfo("Posizione trovata, ma il servizio WFS dell'Agenzia non ha restituito il confine. Riprova tra poco.");
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) {
clearManagedCategorySelection("gpsParcels");
targetGroup.clearLayers();
}
const existingLayer = normalizedParcel
? findParcelLayerByReference(targetGroup, ref) || restoreHiddenParcelLayerByReference(ref)
: null;
if (existingLayer) {
applyStyleFromProperties(existingLayer);
targetGroup.bringToFront();
if (shouldFit) {
fitLayer(existingLayer);
}
setText("parcelState", `Particella selezionata: ${ref}`);
setInfo(`La particella ${ref} e' gia evidenziata sulla mappa.`);
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),
_lmeColor: feature.properties?._lmeColor || (normalizedParcel ? "#ef4444" : "#1d4ed8"),
_lmeFillColor: feature.properties?._lmeFillColor || (normalizedParcel ? "#fbbf24" : "#38bdf8"),
_lmeWeight: Number(feature.properties?._lmeWeight) || 4,
_lmeOpacity: Number.isFinite(Number(feature.properties?._lmeOpacity)) ? Number(feature.properties._lmeOpacity) : 1,
_lmeFillOpacity: Number.isFinite(Number(feature.properties?._lmeFillOpacity)) ? Number(feature.properties._lmeFillOpacity) : (normalizedParcel ? 0.26 : 0.2)
};
const layer = L.geoJSON(feature, {
pane: normalizedParcel ? "selectedParcelsPane" : "gpsParcelsPane",
style: normalizedParcel
? { color: "#ef4444", weight: 4, opacity: 1, fillColor: "#fbbf24", fillOpacity: 0.26 }
: { color: "#1d4ed8", weight: 4, opacity: 1, fillColor: "#38bdf8", fillOpacity: 0.2 }
}).addTo(targetGroup);
layer.lmeParcelReference = ref;
layer.bindPopup(`<strong>${escapeHtml(ref)}</strong>`);
bindParcelMapSelection(layer, normalizedParcel ? "selectedParcels" : "gpsParcels", ref);
if (normalizedParcel) {
targetGroup.bringToFront();
}
if (shouldFit) {
fitLayer(layer);
}
if (normalizedParcel) {
setText("parcelState", `Particella selezionata: ${ref}`);
const count = lmeState.selectedParcelsLayer.getLayers().length;
setInfo(`Aggiunta ${ref}. Particelle evidenziate: ${count}.`);
} else {
setText("parcelState", `Dentro particella AdE: ${ref}`);
}
updateGpsInsideState();
refreshLayerManager();
return normalizedParcel ? "added" : "gps";
} catch (error) {
setInfo(`Geometria catastale non leggibile: ${error.message}`);
return "error";
}
}
function normalizeParcel(parcel) {
return String(parcel || "").trim().toUpperCase();
}
function findParcelLayerByReference(group, reference) {
const expected = normalizeParcelReference(reference);
let match = null;
group.eachLayer(layer => {
if (match) {
return;
}
if (parcelLayerMatchesReference(layer, expected)) {
match = layer;
return;
}
if (typeof layer.eachLayer === "function") {
match = findParcelLayerByReference(layer, reference);
}
});
return match;
}
function bindParcelMapSelection(geoJsonLayer, categoryKey, reference) {
if (!geoJsonLayer || typeof geoJsonLayer.eachLayer !== "function") return;
geoJsonLayer.eachLayer(layer => {
layer.lmeParcelReference = reference;
layer.bindPopup?.(`<strong>${escapeHtml(reference)}</strong>`);
applyStyleFromProperties(layer);
bindManagedMapSelection(layer, categoryKey, geoJsonLayer);
});
}
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;
}
window.setTimeout(() => lmeState.map.invalidateSize({ pan: false }), 50);
}
function exportCadastreView() {
const layers = activeCadastreLayers();
if (!layers.length) {
setInfo("Attiva almeno un layer catastale prima di aprire la vista WMS.");
return;
}
const bounds = lmeState.map.getBounds();
const size = lmeState.map.getSize();
const scale = Math.min(1, 1200 / size.x, 1200 / size.y);
const width = Math.max(256, Math.round(size.x * scale));
const height = Math.max(256, Math.round(size.y * scale));
const bbox = [bounds.getSouth(), bounds.getWest(), bounds.getNorth(), bounds.getEast()].join(",");
const params = new URLSearchParams({
service: "WMS",
version: "1.3.0",
request: "GetMap",
layers: layers.join(","),
styles: "",
format: "image/png",
transparent: "true",
width: String(width),
height: String(height),
crs: "EPSG:4258",
bbox
});
const preview = document.getElementById("wmsPreview");
const previewBody = preview?.querySelector(".wms-preview-body");
const previewMap = document.getElementById("wmsPreviewMap");
const image = document.getElementById("wmsPreviewImage");
const overlay = document.getElementById("wmsPreviewOverlay");
const loading = document.getElementById("wmsPreviewLoading");
if (!preview || !previewBody || !previewMap || !image || !overlay || !loading) {
setInfo("Vista WMS interna non disponibile.");
return;
}
preview.hidden = false;
const availableWidth = Math.max(1, previewBody.clientWidth - 24);
const availableHeight = Math.max(1, previewBody.clientHeight - 24);
const displayScale = Math.min(1, availableWidth / width, availableHeight / height);
previewMap.style.width = `${Math.max(1, Math.round(width * displayScale))}px`;
previewMap.style.height = `${Math.max(1, Math.round(height * displayScale))}px`;
previewMap.hidden = true;
renderPreviewParcelOverlay(overlay, bounds, width, height);
loading.textContent = "Caricamento...";
loading.hidden = false;
image.onload = () => {
loading.hidden = true;
previewMap.hidden = false;
const highlightedCount = lmeState.selectedParcelsLayer.getLayers().length
+ lmeState.gpsParcelLayer.getLayers().length;
const zoomHint = layers.includes("codice_plla") && lmeState.map.getZoom() < 17
? " Ingrandisci almeno fino a zoom 17 per leggere i numeri delle particelle."
: "";
setInfo(`Vista WMS caricata con ${layers.length} layer e ${highlightedCount} particelle evidenziate.${zoomHint}`);
};
image.onerror = () => {
previewMap.hidden = true;
loading.textContent = "Il servizio WMS non ha restituito l'immagine. Riprova tra poco.";
setInfo("Vista WMS non disponibile.");
};
image.src = `${cadastreUrl()}${params}`;
}
function closeCadastrePreview() {
const preview = document.getElementById("wmsPreview");
const previewMap = document.getElementById("wmsPreviewMap");
const image = document.getElementById("wmsPreviewImage");
const overlay = document.getElementById("wmsPreviewOverlay");
if (preview) {
preview.hidden = true;
}
if (previewMap) {
previewMap.hidden = true;
}
if (image) {
image.onload = null;
image.onerror = null;
image.removeAttribute("src");
}
overlay?.replaceChildren();
}
function renderPreviewParcelOverlay(svg, bounds, width, height) {
svg.replaceChildren();
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
appendPreviewGeometry(svg, lmeState.selectedParcelsLayer.toGeoJSON(), "wms-preview-parcel", bounds, width, height);
appendPreviewGeometry(svg, lmeState.gpsParcelLayer.toGeoJSON(), "wms-preview-gps-parcel", bounds, width, height);
}
function appendPreviewGeometry(svg, featureCollection, className, bounds, width, height) {
const namespace = "http://www.w3.org/2000/svg";
for (const feature of featureCollection?.features || []) {
for (const polygon of geometryPolygons(feature.geometry)) {
const pathData = polygon
.map(ring => ring
.map((coordinate, index) => {
const point = previewPixel(coordinate, bounds, width, height);
return `${index ? "L" : "M"}${point.x.toFixed(2)} ${point.y.toFixed(2)}`;
})
.join(" ") + " Z")
.join(" ");
if (!pathData.trim()) {
continue;
}
const path = document.createElementNS(namespace, "path");
path.setAttribute("d", pathData);
path.setAttribute("class", className);
svg.appendChild(path);
}
}
}
function geometryPolygons(geometry) {
if (!geometry) {
return [];
}
if (geometry.type === "Polygon") {
return [geometry.coordinates || []];
}
if (geometry.type === "MultiPolygon") {
return geometry.coordinates || [];
}
if (geometry.type === "GeometryCollection") {
return (geometry.geometries || []).flatMap(geometryPolygons);
}
return [];
}
function previewPixel(coordinate, bounds, width, height) {
const longitude = Number(coordinate?.[0]);
const latitude = Number(coordinate?.[1]);
return {
x: ((longitude - bounds.getWest()) / (bounds.getEast() - bounds.getWest())) * width,
y: ((bounds.getNorth() - latitude) / (bounds.getNorth() - bounds.getSouth())) * height
};
}
function bindFileImport() {
if (lmeState.fileImportBound) {
return;
}
document.addEventListener("change", async event => {
if (event.target?.id !== "gisFileInput") {
return;
}
const file = event.target.files?.[0];
if (!file) {
return;
}
try {
if (file.size > 100 * 1024 * 1024) throw new Error("file superiore a 100 MB; suddividilo prima dell'importazione");
setInfo(`Analisi di ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)...`);
await new Promise(resolve => window.setTimeout(resolve, 20));
await importGisFile(file);
} catch (error) {
setInfo(`File non leggibile: ${error.message}`);
} finally {
event.target.value = "";
}
});
lmeState.fileImportBound = true;
}
async function importExternalGisFile(fileName, base64Content, mimeType) {
const binary = atob(base64Content);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
const file = new File([bytes], fileName, { type: mimeType || "application/octet-stream" });
if (file.size > 100 * 1024 * 1024) {
throw new Error("file superiore a 100 MB; suddividilo prima dell'importazione");
}
setInfo(`Analisi di ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)...`);
await new Promise(resolve => window.setTimeout(resolve, 20));
return importGisFile(file);
}
async function importGisFile(file) {
const result = await readGisFile(file);
rememberMapChange();
const geojson = result.geojson;
const parcelLayer = addImportedFeatures(geojson);
const sheetGeojson = result.sheetGeojson || emptyFeatureCollection();
const sheetLayer = addImportedSheetFeatures(sheetGeojson);
applyImportedSheetVisibility();
fitLayer(geojson.features.length ? parcelLayer : sheetLayer);
refreshImportedMeasurements();
const featureCount = geojson.features.length;
const sheetCount = sheetGeojson.features.length;
const ignored = result.ignoredCount
? `; esclusi ${result.ignoredCount} elementi del foglio o non particella`
: "";
const includedSheets = sheetCount
? `; ${sheetCount} elementi foglio ${lmeState.importedSheetsVisible ? "visibili" : "nascosti"}`
: "";
const reprojected = result.reprojected ? "; coordinate Web Mercator convertite" : "";
const warnings = result.validationWarnings ? `; ${result.validationWarnings} geometrie da verificare` : "";
const message = `Caricato ${file.name}: ${featureCount} particelle/elementi${includedSheets}${ignored}${reprojected}${warnings}.`;
setInfo(message);
updateGpsInsideState();
refreshLayerManager();
finishMapChange();
return message;
}
async function readGisFile(file) {
const lowerName = file.name.toLowerCase();
if (lowerName.endsWith(".geojson") || lowerName.endsWith(".json")) {
const text = (await readFileText(file)).replace(/^\uFEFF/, "").trim();
if (!text) {
throw new Error("GeoJSON vuoto");
}
let parsed;
try {
parsed = JSON.parse(text);
if (typeof parsed === "string") {
parsed = JSON.parse(parsed);
}
} catch (error) {
throw new Error(`GeoJSON non valido: ${error.message}`);
}
return normalizeGeoJson(parsed, file.name);
}
if (lowerName.endsWith(".kml")) {
return normalizeKmlFiles([{ name: file.name, text: await readFileText(file) }]);
}
if (lowerName.endsWith(".kmz")) {
const zip = await JSZip.loadAsync(await file.arrayBuffer());
const kmlEntries = Object.values(zip.files).filter(entry =>
!entry.dir
&& !entry.name.startsWith("__MACOSX/")
&& entry.name.toLowerCase().endsWith(".kml"));
if (!kmlEntries.length) {
throw new Error("KMZ senza file KML interno");
}
const files = await Promise.all(kmlEntries.map(async entry => ({
name: entry.name,
text: await entry.async("text")
})));
return normalizeKmlFiles(files);
}
throw new Error("Formato supportato: KML, KMZ, GeoJSON");
}
function readFileText(file) {
if (typeof file.text === "function") {
return file.text();
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("lettura del file non riuscita"));
reader.readAsText(file);
});
}
function normalizeGeoJson(value, fileName) {
let root;
if (Array.isArray(value)) {
root = {
type: "FeatureCollection",
features: value.map(item => item?.type === "Feature"
? item
: { type: "Feature", properties: {}, geometry: item })
};
} else if (value?.type === "FeatureCollection") {
root = value;
} else if (value?.type === "Feature") {
root = { type: "FeatureCollection", features: [value], crs: value.crs };
} else if (isGeoJsonGeometry(value)) {
root = {
type: "FeatureCollection",
features: [{ type: "Feature", properties: {}, geometry: value }],
crs: value.crs
};
} else {
throw new Error("il contenuto non e' un Feature, una geometria o una FeatureCollection");
}
root.features = (root.features || []).filter(feature => feature?.type === "Feature" && feature.geometry);
if (!root.features.length) {
throw new Error("nessuna geometria presente nel GeoJSON");
}
root.features.forEach(feature => {
feature.properties = feature.properties && typeof feature.properties === "object"
? feature.properties
: {};
feature.properties._lmeSource = feature.properties._lmeSource || fileName;
});
const crsText = root.crs ? JSON.stringify(root.crs).toUpperCase() : "";
const coordinates = collectGeoJsonCoordinates(root);
const hasProjectedCoordinates = coordinates.some(point => Math.abs(point[0]) > 180 || Math.abs(point[1]) > 90);
const webMercator = /EPSG[^0-9]*(3857|900913)/.test(crsText)
|| (!crsText && hasProjectedCoordinates && coordinates.every(point =>
Math.abs(point[0]) <= 20037508.35 && Math.abs(point[1]) <= 20048966.1));
if (webMercator) {
root.features.forEach(feature => {
feature.geometry = transformGeoJsonGeometry(feature.geometry, webMercatorToWgs84);
});
} else if (crsText && !/(4326|4258|CRS84|CRS:84)/.test(crsText)) {
throw new Error(`sistema di coordinate non supportato (${crsText.slice(0, 80)})`);
}
let validationWarnings = 0;
const unique = new Set();
root.features = root.features.filter(feature => {
try { turf.cleanCoords(feature, { mutate: true }); } catch { }
const key = JSON.stringify(feature.geometry);
if (unique.has(key)) return false;
unique.add(key);
if (typeof turf.booleanValid === "function" && !turf.booleanValid(feature)) {
feature.properties._lmeValidationWarning = "Geometria non valida: usa Controlla e ripara";
validationWarnings++;
}
return true;
});
if (root.features.length > 5000 && window.confirm(`Il file contiene ${root.features.length} geometrie. Semplificarle leggermente per migliorare le prestazioni?`)) {
root.features = root.features.map(feature => {
try { return turf.simplify(feature, { tolerance: 0.000001, highQuality: true, mutate: false }); } catch { return feature; }
});
}
const normalizedCoordinates = collectGeoJsonCoordinates(root);
if (!normalizedCoordinates.length || normalizedCoordinates.some(point =>
!Number.isFinite(point[0])
|| !Number.isFinite(point[1])
|| Math.abs(point[0]) > 180
|| Math.abs(point[1]) > 90)) {
throw new Error("coordinate non valide o non espresse in WGS84/EPSG:4326");
}
delete root.crs;
return {
geojson: root,
sheetGeojson: emptyFeatureCollection(),
ignoredCount: 0,
reprojected: webMercator,
validationWarnings
};
}
function isGeoJsonGeometry(value) {
return [
"Point",
"MultiPoint",
"LineString",
"MultiLineString",
"Polygon",
"MultiPolygon",
"GeometryCollection"
].includes(value?.type);
}
function collectGeoJsonCoordinates(geojson) {
const points = [];
const visitGeometry = geometry => {
if (!geometry) {
return;
}
if (geometry.type === "GeometryCollection") {
(geometry.geometries || []).forEach(visitGeometry);
return;
}
const visit = coordinates => {
if (Array.isArray(coordinates)
&& coordinates.length >= 2
&& typeof coordinates[0] === "number"
&& typeof coordinates[1] === "number") {
points.push(coordinates);
return;
}
if (Array.isArray(coordinates)) {
coordinates.forEach(visit);
}
};
visit(geometry.coordinates);
};
(geojson.features || []).forEach(feature => visitGeometry(feature.geometry));
return points;
}
function transformGeoJsonGeometry(geometry, transform) {
if (!geometry) {
return geometry;
}
if (geometry.type === "GeometryCollection") {
return {
...geometry,
geometries: (geometry.geometries || []).map(item => transformGeoJsonGeometry(item, transform))
};
}
const visit = coordinates => {
if (Array.isArray(coordinates)
&& coordinates.length >= 2
&& typeof coordinates[0] === "number"
&& typeof coordinates[1] === "number") {
return transform(coordinates);
}
return Array.isArray(coordinates) ? coordinates.map(visit) : coordinates;
};
return { ...geometry, coordinates: visit(geometry.coordinates) };
}
function webMercatorToWgs84(coordinate) {
const longitude = coordinate[0] * 180 / 20037508.34;
const latitudeRadians = Math.PI / 2 - 2 * Math.atan(Math.exp(-coordinate[1] / 6378137));
return [longitude, latitudeRadians * 180 / Math.PI, ...coordinate.slice(2)];
}
function normalizeKmlFiles(files) {
const features = files.flatMap(file => kmlTextToGeoJson(file.text, file.name));
if (!features.length) {
throw new Error("KML senza geometrie utilizzabili");
}
if (!looksLikeCadastralKml(features)) {
return {
geojson: { type: "FeatureCollection", features },
sheetGeojson: emptyFeatureCollection(),
ignoredCount: 0,
reprojected: false
};
}
const polygonParts = splitKmlPolygonFeatures(features);
const parcels = filterKmlParcelFeatures(polygonParts);
const parcelSet = new Set(parcels);
const sheetParts = polygonParts.filter(feature => !parcelSet.has(feature));
const nonPolygonFeatures = features.filter(feature => geometryPolygons(feature.geometry).length === 0);
const importedFeatures = [...parcels, ...nonPolygonFeatures];
const optionalFeatures = sheetParts;
if (!importedFeatures.length && !(lmeState.includeKmlSheetLayer && optionalFeatures.length)) {
throw new Error("nessuna particella trovata; attiva 'includi layer del foglio' per importare gli altri elementi");
}
return {
geojson: { type: "FeatureCollection", features: importedFeatures },
sheetGeojson: {
type: "FeatureCollection",
features: lmeState.includeKmlSheetLayer ? optionalFeatures : []
},
ignoredCount: lmeState.includeKmlSheetLayer ? 0 : optionalFeatures.length,
reprojected: false
};
}
function looksLikeCadastralKml(features) {
return features.some(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(" ").toLowerCase();
return /\b(particell|mappal|catast|foglio|parcel|cadastral)\w*/i.test(context);
});
}
function kmlTextToGeoJson(text, sourceName) {
const xml = new DOMParser().parseFromString(text, "text/xml");
if (xml.getElementsByTagName("parsererror").length) {
throw new Error(`KML non valido (${sourceName})`);
}
const tree = toGeoJSON.kmlWithFolders(xml, { skipNullGeometry: true });
const features = [];
const visit = (node, folders) => {
if (node?.type === "Feature") {
const sourceProperties = node.properties || {};
node.properties = {
...sourceProperties,
_lmeColor: sourceProperties._lmeColor || sourceProperties.stroke,
_lmeFillColor: sourceProperties._lmeFillColor || sourceProperties.fill,
_lmeWeight: sourceProperties._lmeWeight || sourceProperties["stroke-width"],
_lmeOpacity: sourceProperties._lmeOpacity || sourceProperties["stroke-opacity"],
_lmeFillOpacity: sourceProperties._lmeFillOpacity || sourceProperties["fill-opacity"],
_lmeFolder: folders.filter(Boolean).join(" / "),
_lmeSource: sourceName
};
features.push(node);
return;
}
const nextFolders = node?.type === "folder"
? [...folders, node.meta?.name || ""]
: folders;
(node?.children || []).forEach(child => visit(child, nextFolders));
};
visit(tree, []);
return features;
}
function filterKmlParcelFeatures(features) {
const filtered = features.filter(feature => {
if (isSheetOrOverlayFeature(feature)) {
return false;
}
const properties = feature.properties || {};
const name = String(properties.name || properties.Name || properties.LABEL || "").trim();
const identity = [name, properties.layer, properties.Layer, properties.type, properties.Tipo]
.filter(Boolean)
.join(" ")
.toLowerCase();
const folder = String(properties._lmeFolder || "").toLowerCase();
const context = `${identity} ${folder}`;
const hasParcelNumber = /^[a-z]?\s*\d+[a-z]?(?:[\/. -]\d+[a-z]?)?$/i.test(name);
const isSheet = /\bfoglio\b|\bsheet\b|quadro[ _-]?d[' ]?unione|(?:perimetro|bordo|limite|contorno)[ _-]?(?:del[ _-]?)?foglio|sheet[ _-]?boundary/.test(context);
return !isSheet || hasParcelNumber;
});
return removeLikelySheetOutlines(filtered);
}
function isSheetOrOverlayFeature(feature) {
const properties = feature?.properties || {};
const geometryType = String(properties["@geometry-type"] || "").toLowerCase();
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(" ").toLowerCase();
if (geometryType === "groundoverlay" || /^doc[_ -]?\d+$/i.test(name)) {
return true;
}
const hasParcelNumber = /^[a-z]?\s*\d+[a-z]?(?:[\/. -]\d+[a-z]?)?$/i.test(name);
return !hasParcelNumber
&& /\bfoglio\b|\bsheet\b|quadro[ _-]?d[' ]?unione|(?:perimetro|bordo|limite|contorno)[ _-]?(?:del[ _-]?)?foglio|sheet[ _-]?boundary/.test(context);
}
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 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);
}
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) {
const wrapper = { features: [{ geometry }] };
const points = collectGeoJsonCoordinates(wrapper);
if (!points.length) {
return null;
}
return points.reduce((bounds, point) => ({
west: Math.min(bounds.west, point[0]),
south: Math.min(bounds.south, point[1]),
east: Math.max(bounds.east, point[0]),
north: Math.max(bounds.north, point[1])
}), { west: Infinity, south: Infinity, east: -Infinity, north: -Infinity });
}
function boundsArea(bounds) {
return Math.max(0, bounds.east - bounds.west) * Math.max(0, bounds.north - bounds.south);
}
function boundsCenter(bounds) {
return [(bounds.west + bounds.east) / 2, (bounds.south + bounds.north) / 2];
}
function boundsContains(bounds, point) {
return point[0] >= bounds.west && point[0] <= bounds.east
&& point[1] >= bounds.south && point[1] <= bounds.north;
}
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 setImportedLayerVisible(enabled) {
lmeState.importedVisible = Boolean(enabled);
if (!lmeState.map || !lmeState.imported) {
return;
}
if (lmeState.importedVisible) {
if (!lmeState.map.hasLayer(lmeState.imported)) {
lmeState.imported.addTo(lmeState.map);
}
lmeState.imported.bringToFront();
const count = lmeState.imported.toGeoJSON().features?.length || 0;
setInfo(count
? `${count} particelle o geometrie importate visibili.`
: "Nessun dato importato da mostrare.");
return;
}
if (lmeState.map.hasLayer(lmeState.imported)) {
lmeState.map.removeLayer(lmeState.imported);
}
setInfo("Dati importati nascosti; restano disponibili nel file o progetto.");
}
function managedLayerPanes() {
return {
gpsPosition: "gpsPositionPane",
drawings: "drawingsPane",
gpsTrail: "gpsTrailPane",
gpsParcels: "gpsParcelsPane",
selectedParcels: "selectedParcelsPane",
fiducials: "fiducialsPane",
importedSheets: "importedSheetPane",
imported: "importedPane",
compass: "compassPane",
cadastre: "cadastrePane"
};
}
function setLayerOrder(order) {
const panes = managedLayerPanes();
const requested = Array.isArray(order) ? order.filter(key => panes[key]) : [];
const normalized = [...new Set([...requested, ...Object.keys(panes)])];
lmeState.layerOrder = normalized;
if (!lmeState.map) {
return;
}
normalized.forEach((key, index) => {
const zIndex = String(590 - index * 20);
const paneNames = [panes[key]];
if (key === "drawings") paneNames.push("drawingsMarkerPane");
if (key === "gpsPosition") paneNames.push("gpsAccuracyPane");
paneNames.forEach(paneName => {
const pane = lmeState.map.getPane(paneName);
if (pane) pane.style.zIndex = zIndex;
});
});
try {
localStorage.setItem("lmeMapLayerOrder", JSON.stringify(normalized));
} catch {
}
}
function restoreLayerOrder() {
try {
const saved = JSON.parse(localStorage.getItem("lmeMapLayerOrder") || "null");
if (Array.isArray(saved)) {
const remaining = saved.filter(key => !["gpsPosition", "gpsTrail", "fiducials"].includes(key));
if (!remaining.includes("compass")) {
const cadastreIndex = remaining.indexOf("cadastre");
remaining.splice(cadastreIndex >= 0 ? cadastreIndex : remaining.length, 0, "compass");
}
lmeState.layerOrder = ["gpsPosition", "gpsTrail", "fiducials", ...remaining];
}
} catch {
}
}
function saveLayerManagerState() {
// Object order and visibility are serialized in the project snapshot.
}
function applyStoredLayerManagerVisibility() {
layerManagerCategories().forEach(category => {
if (lmeState.managerHiddenCategories.has(category.key)) {
category.roots.forEach(layer => {
if (layer && lmeState.map.hasLayer(layer)) {
lmeState.map.removeLayer(layer);
}
});
}
managedRecordsForCategory(category).forEach(record => {
if (lmeState.managerHiddenObjectIds.has(record.id)
&& !lmeState.managerHiddenObjects.has(record.id)) {
lmeState.managerHiddenObjects.set(record.id, record);
record.parent.removeLayer(record.layer);
}
});
});
}
function managedGeoJsonLabel(geojson, fallback) {
const features = geojson?.features || [];
const source = features.map(feature => feature?.properties?._lmeSource).find(Boolean);
return `${source || fallback} (${features.length})`;
}
function drawingLayerLabel(layer, layerType) {
const properties = layer?.feature?.properties || {};
if (properties.name) {
return `POI: ${properties.name}`;
}
const shape = layerType || properties._lmeShape || "disegno";
const labels = {
polygon: "Area",
rectangle: "Rettangolo",
polyline: "Linea",
circle: "Cerchio",
marker: "POI"
};
return labels[shape] || "Disegno";
}
function layerManagerCategories() {
return [
{ key: "gpsPosition", label: "Posizione GPS", roots: [lmeState.gpsMarker, lmeState.gpsAccuracy].filter(Boolean) },
{ key: "drawings", label: "Disegni e POI", roots: [lmeState.drawings].filter(Boolean), blocks: true },
{ key: "gpsTrail", label: "Traccia GPS", roots: [lmeState.gpsTrail].filter(Boolean) },
{ key: "gpsParcels", label: "Particella rilevata dal GPS", roots: [lmeState.gpsParcelLayer].filter(Boolean) },
{ key: "selectedParcels", label: "Particelle catastali evidenziate", roots: [lmeState.selectedParcelsLayer].filter(Boolean) },
{ key: "fiducials", label: "Punti fiduciali", roots: [lmeState.fiducials].filter(Boolean), blocks: true },
{ key: "importedSheets", label: "Cornici e fogli importati", roots: [lmeState.importedSheets].filter(Boolean), blocks: true },
{ key: "imported", label: "Progetti e dati importati", roots: [lmeState.imported].filter(Boolean), blocks: true },
{ key: "compass", label: "Bussola", roots: [lmeState.compassLayer].filter(Boolean), uiOnly: true },
{ key: "cadastre", label: "Layer catastali AdE", roots: [lmeState.cadastre].filter(Boolean) }
];
}
function managedLayerId(layer, categoryKey) {
const properties = layer?.feature?.properties;
if (properties) {
properties._lmeManagerId ||= `${categoryKey}-${L.stamp(layer)}`;
return properties._lmeManagerId;
}
layer.lmeManagerId ||= `${categoryKey}-${L.stamp(layer)}`;
return layer.lmeManagerId;
}
function collectManagedLeafLayers(layer, categoryKey, parent, records) {
if (!layer) {
return;
}
if (typeof layer.getLayers === "function") {
const children = layer.getLayers();
children.forEach(child => collectManagedLeafLayers(child, categoryKey, layer, records));
return;
}
if (layer instanceof L.Polyline && !lineLatLngGroups(layer.getLatLngs()).some(line => line.length)) {
return;
}
const id = managedLayerId(layer, categoryKey);
records.push({
id,
categoryKey,
layer,
parent,
source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente")
});
}
function managedRecordsForCategory(category) {
if (category.uiOnly) {
return [];
}
const records = [];
category.roots.forEach(root => collectManagedLeafLayers(root, category.key, lmeState.map, records));
lmeState.managerHiddenObjects.forEach(record => {
if (record.categoryKey === category.key && !records.some(item => item.id === record.id)) {
records.push(record);
}
});
return records;
}
function managedObjectLabel(record, index) {
const layer = record.layer;
const properties = layer?.feature?.properties || {};
const reference = properties._lmeDisplayName
|| properties.name
|| properties.Name
|| properties.NATIONALCADASTRALREFERENCE
|| properties.INSPIREID_LOCALID
|| properties.LABEL
|| properties.label;
if (reference) {
return String(reference);
}
if (layer.lmeManagerLabel) {
return layer.lmeManagerLabel;
}
if (layer instanceof L.Circle) {
return `Cerchio ${index + 1}`;
}
if (layer instanceof L.Polygon) {
return `Area/particella ${index + 1}`;
}
if (layer instanceof L.Polyline) {
return `Linea ${index + 1}`;
}
if (layer instanceof L.Marker) {
return `POI ${index + 1}`;
}
return `Oggetto ${index + 1}`;
}
function managedObjectEditableName(record) {
const properties = record?.layer?.feature?.properties || {};
return properties._lmeDisplayName || properties.name || properties.Name || properties.label || properties.LABEL || "";
}
function managedObjectCanRename(record) {
return ["drawings", "imported", "importedSheets"].includes(record.categoryKey)
&& record.layer?.feature?.properties;
}
async function renameManagedObject(record, index) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (!managedObjectCanRename(record)) {
return;
}
const currentName = String(managedObjectEditableName(record) || managedObjectLabel(record, index)).trim();
const result = await showFormDialog("Rinomina oggetto", [{ name: "name", label: "Nome", value: currentName, maxLength: 120 }], "Rinomina");
if (!result) return;
const requestedName = result.name;
const name = requestedName.trim().slice(0, 120);
if (!name) {
setInfo("Nome non modificato: inserisci un testo.");
return;
}
rememberMapChange();
const properties = record.layer.feature.properties;
properties.name = name;
properties._lmeDisplayName = name;
record.layer.lmeManagerLabel = record.categoryKey === "drawings" && record.layer instanceof L.Marker
? `POI: ${name}`
: null;
updateStoredFeaturePopup(record.layer);
refreshImportedMeasurements();
refreshDrawingMeasurements();
refreshPermanentLabels();
refreshLayerManager();
finishMapChange();
setInfo(`Oggetto rinominato: ${name}.`);
}
function managedCategoryName(category, records) {
const storedName = lmeState.managerCategoryNames[category.key];
const featureName = records
.map(record => record.layer?.feature?.properties?._lmeCategoryName)
.find(Boolean);
const name = String(storedName || featureName || category.label).trim();
if (name !== category.label) {
lmeState.managerCategoryNames[category.key] = name;
}
return name;
}
function managedCategoryCanRename(category) {
return ["drawings", "imported", "importedSheets"].includes(category.key);
}
async function renameManagedCategory(category, records) {
const currentName = managedCategoryName(category, records);
const result = await showFormDialog("Rinomina gruppo", [{ name: "name", label: "Nome gruppo", value: currentName, maxLength: 120 }], "Rinomina");
if (!result) return;
const requestedName = result.name;
const name = requestedName.trim().slice(0, 120);
if (!name) {
setInfo("Nome non modificato: inserisci un testo.");
return;
}
rememberMapChange();
lmeState.managerCategoryNames[category.key] = name;
records.forEach(record => {
if (record.layer?.feature?.properties) {
record.layer.feature.properties._lmeCategoryName = name;
}
});
refreshLayerManager();
finishMapChange();
setInfo(`Gruppo rinominato: ${name}.`);
}
function managedBlockName(source, records) {
return String(records
.map(record => record.layer?.feature?.properties?._lmeGroupName)
.find(Boolean) || source);
}
async function renameManagedBlock(category, source, records) {
const currentName = managedBlockName(source, records);
const result = await showFormDialog("Rinomina gruppo", [{ name: "name", label: "Nome gruppo", value: currentName, maxLength: 120 }], "Rinomina");
if (!result) return;
const requestedName = result.name;
const name = requestedName.trim().slice(0, 120);
if (!name) {
setInfo("Nome non modificato: inserisci un testo.");
return;
}
rememberMapChange();
records.forEach(record => {
if (record.layer?.feature?.properties) {
record.layer.feature.properties._lmeGroupName = name;
}
});
refreshLayerManager();
finishMapChange();
setInfo(`Gruppo rinominato: ${name}.`);
}
function managedCategoryCanRemove(category, records) {
return records.length > 0
&& ["drawings", "imported", "importedSheets", "selectedParcels", "gpsParcels", "fiducials"].includes(category.key);
}
function managedObjectCanRemove(record) {
return ["drawings", "imported", "importedSheets", "selectedParcels", "gpsParcels", "fiducials"].includes(record.categoryKey);
}
function managedCategoryCanMove(categoryKey) {
return ["drawings", "imported", "importedSheets", "selectedParcels"].includes(categoryKey);
}
function layerManagerRowActions(renameButton, moveButton, removeButton) {
if (!renameButton && !moveButton && !removeButton) {
return "";
}
return `<span class="layer-row-actions">${renameButton}${moveButton}${removeButton}</span>`;
}
const layerManagerPalettes = [
{ surface: "#f0fdf4", parent: "#dcfce7", border: "#86efac", accent: "#166534" },
{ surface: "#eff6ff", parent: "#dbeafe", border: "#93c5fd", accent: "#1d4ed8" },
{ surface: "#fffbeb", parent: "#fef3c7", border: "#fcd34d", accent: "#92400e" },
{ surface: "#fff1f2", parent: "#ffe4e6", border: "#fda4af", accent: "#be123c" },
{ surface: "#f5f3ff", parent: "#ede9fe", border: "#c4b5fd", accent: "#6d28d9" },
{ surface: "#ecfeff", parent: "#cffafe", border: "#67e8f9", accent: "#0e7490" },
{ surface: "#f7fee7", parent: "#ecfccb", border: "#bef264", accent: "#3f6212" }
];
function applyLayerManagerPalette(row, key) {
const hash = [...String(key)].reduce((value, character) =>
((value * 31) + character.charCodeAt(0)) >>> 0, 0);
const palette = layerManagerPalettes[hash % layerManagerPalettes.length];
row.style.setProperty("--layer-group-surface", palette.surface);
row.style.setProperty("--layer-group-parent", palette.parent);
row.style.setProperty("--layer-group-border", palette.border);
row.style.setProperty("--layer-group-accent", palette.accent);
}
function clearManagedObjectState(id) {
lmeState.managerSelectedObjectIds.delete(id);
lmeState.managerHiddenObjects.delete(id);
lmeState.managerHiddenObjectIds.delete(id);
lmeState.managerHiddenMeasurementSummaryIds.delete(id);
lmeState.managerHiddenMeasurementSideIds.delete(id);
Object.keys(lmeState.managerObjectOrder).forEach(categoryKey => {
lmeState.managerObjectOrder[categoryKey] = lmeState.managerObjectOrder[categoryKey]
.filter(objectId => objectId !== id);
});
}
function removeManagedRecords(records, subject) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (!records.length) {
return;
}
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) {
setInfo("Operazione annullata: sblocca prima tutti gli oggetti coinvolti.");
return;
}
const message = records.length === 1
? `Rimuovere ${subject} dal progetto? Non verra incluso nel prossimo salvataggio.`
: `Rimuovere ${subject} e tutti i suoi ${records.length} oggetti dal progetto? Non verranno inclusi nel prossimo salvataggio.`;
if (!window.confirm(message)) {
return;
}
rememberMapChange();
lmeState.trash.push({
removedAt: new Date().toISOString(),
subject,
items: records.map(record => ({
categoryKey: record.categoryKey,
source: record.source,
feature: record.layer.toGeoJSON()
}))
});
if (lmeState.trash.length > 30) lmeState.trash.shift();
const removedIds = new Set(records.map(record => record.id));
const categoryKeys = new Set(records.map(record => record.categoryKey));
records.forEach(record => {
if (record.parent?.hasLayer?.(record.layer)) {
record.parent.removeLayer(record.layer);
}
clearManagedObjectState(record.id);
});
categoryKeys.forEach(categoryKey => {
const order = lmeState.managerObjectOrder[categoryKey] || [];
lmeState.managerObjectOrder[categoryKey] = order.filter(id => !removedIds.has(id));
const category = layerManagerCategories().find(item => item.key === categoryKey);
if (category && managedRecordsForCategory(category).length === 0) {
lmeState.managerRemovedCategories.add(categoryKey);
delete lmeState.managerCategoryNames[categoryKey];
}
});
clearManagerHighlight();
refreshImportedMeasurements();
refreshDrawingMeasurements();
updateGpsInsideState();
refreshLayerManager();
finishMapChange(`Rimozione: ${subject}`);
setInfo(`${records.length === 1 ? "Oggetto rimosso" : `${records.length} oggetti rimossi`} dal progetto.`);
}
function managedMoveDestinations() {
return layerManagerCategories()
.filter(category => ["drawings", "imported"].includes(category.key))
.map(category => {
const records = managedRecordsForCategory(category);
const groups = new Map();
records.forEach(record => {
if (!groups.has(record.source)) {
groups.set(record.source, managedBlockName(record.source, [record]));
}
});
if (!groups.size) {
groups.set("Progetto corrente", "Gruppo principale");
}
return {
key: category.key,
name: managedCategoryName(category, records),
groups: [...groups].map(([source, name]) => ({ source, name }))
};
});
}
function showMoveManagedRecordsDialog(records, subject) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (!records.length || records.some(record => !managedCategoryCanMove(record.categoryKey))) {
setInfo("Questo oggetto o gruppo non puo essere spostato.");
return;
}
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) {
setInfo("Sblocca prima tutti gli oggetti da spostare.");
return;
}
const destinations = managedMoveDestinations();
const categoryOptions = destinations.map(destination =>
`<option value="${escapeHtml(destination.key)}">${escapeHtml(destination.name)}</option>`).join("");
const dialog = showAppDialog("Sposta nel gruppo", `<form class="lme-edit-form move-group-form"><p>Cambia l'appartenenza di ${escapeHtml(subject)} senza modificarne la posizione sulla mappa.</p><label>Categoria<select name="category">${categoryOptions}</select></label><label>Gruppo esistente<select name="group"></select></label><label>Oppure crea un nuovo gruppo<input name="newGroup" type="text" maxlength="120" placeholder="Nome del nuovo gruppo"></label><div class="dialog-command-row"><button type="button" data-cancel>Annulla</button><button type="submit" class="primary-command">Sposta nel gruppo</button></div></form>`);
const form = dialog.querySelector("form");
const categorySelect = form.elements.category;
const groupSelect = form.elements.group;
const newGroupInput = form.elements.newGroup;
const sourceCategory = records.every(record => record.categoryKey === records[0].categoryKey)
? records[0].categoryKey
: "drawings";
const currentCategory = destinations.some(destination => destination.key === sourceCategory)
? sourceCategory
: "imported";
categorySelect.value = currentCategory;
const refreshGroups = () => {
const destination = destinations.find(item => item.key === categorySelect.value);
groupSelect.innerHTML = (destination?.groups || []).map(group =>
`<option value="${escapeHtml(group.source)}">${escapeHtml(group.name)}</option>`).join("");
const currentSource = records.every(record => record.source === records[0].source)
? records[0].source
: "";
if (destination?.key === currentCategory && currentSource
&& destination.groups.some(group => group.source === currentSource)) {
groupSelect.value = currentSource;
}
};
categorySelect.addEventListener("change", refreshGroups);
refreshGroups();
dialog.querySelector("[data-cancel]").addEventListener("click", () => dialog.remove());
form.addEventListener("submit", event => {
event.preventDefault();
const destination = destinations.find(item => item.key === categorySelect.value);
if (!destination) return;
const requestedName = newGroupInput.value.trim().slice(0, 120);
const existingByName = requestedName
? destination.groups.find(group => group.name.toLocaleLowerCase("it") === requestedName.toLocaleLowerCase("it"))
: null;
const selectedGroup = destination.groups.find(group => group.source === groupSelect.value);
const target = existingByName || (requestedName
? { source: `Gruppo ${requestedName}`, name: requestedName }
: selectedGroup);
if (!target) {
setInfo("Scegli un gruppo di destinazione o inserisci il nome di un nuovo gruppo.");
return;
}
dialog.remove();
moveManagedRecordsToGroup(records, destination.key, target.source, target.name);
});
}
function moveManagedRecordsToGroup(records, targetCategory, targetSource, targetGroupName) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
const movable = records.filter(record => managedCategoryCanMove(record.categoryKey));
if (!movable.length || !["drawings", "imported"].includes(targetCategory)) {
setInfo("Questo gruppo non puo essere spostato.");
return;
}
if (movable.some(record => record.layer?.feature?.properties?._lmeLocked)) {
setInfo("Sblocca il gruppo prima di spostarlo.");
return;
}
const additionalDrawings = targetCategory === "drawings"
? movable.filter(record => record.categoryKey !== "drawings").length
: 0;
if (!canAddDrawingObjects(additionalDrawings)) return;
rememberMapChange();
const features = movable.map(record => {
const feature = record.layer.toGeoJSON();
feature.properties = {
...(feature.properties || {}),
_lmeSource: targetSource,
_lmeGroupName: targetGroupName
};
delete feature.properties._lmeManagerId;
return feature;
});
movable.forEach(record => {
record.parent?.removeLayer?.(record.layer);
clearManagedObjectState(record.id);
lmeState.managerSelectedObjectIds.delete(record.id);
});
if (targetCategory === "drawings") {
features.forEach(feature => addDrawingFeature(feature));
} else {
addImportedFeatures({ type: "FeatureCollection", features });
}
refreshImportedMeasurements();
refreshDrawingMeasurements();
refreshLayerManager();
const categoryName = targetCategory === "drawings" ? "Disegni e POI" : "Progetti e dati importati";
finishMapChange(`Spostati ${features.length} oggetti in ${categoryName} > ${targetGroupName}.`);
setInfo(`${features.length} oggetti spostati in ${categoryName} > ${targetGroupName}.`);
}
function managedObjectDetails(layer) {
const details = [];
const properties = layer?.feature?.properties || {};
if (properties.name || properties.Name) {
details.push(`Nome: ${properties.name || properties.Name}`);
}
if (layer instanceof L.Circle) {
const radius = layer.getRadius();
details.push(`Raggio: ${formatDistance(radius)}`);
details.push(`Superficie: ${formatArea(Math.PI * radius * radius)}`);
details.push(`Circonferenza: ${formatDistance(2 * Math.PI * radius)}`);
} else if (layer instanceof L.Polygon) {
polygonLatLngGroups(layer.getLatLngs()).forEach((polygon, polygonIndex) => {
const outer = polygon[0] || [];
const area = polygon.reduce((sum, ring, ringIndex) => {
const ringArea = Math.abs(L.GeometryUtil.geodesicArea(ring));
return sum + (ringIndex === 0 ? ringArea : -ringArea);
}, 0);
details.push(`${polygonIndex ? `Area ${polygonIndex + 1}` : "Superficie"}: ${formatArea(Math.abs(area))}`);
details.push(`${polygonIndex ? `Perimetro ${polygonIndex + 1}` : "Perimetro"}: ${formatDistance(latLngPathLength(outer, true))}`);
outer.forEach((point, index) => {
const next = outer[(index + 1) % outer.length];
if (next && !point.equals(next)) {
details.push(`Lato ${index + 1}: ${formatDistance(lmeState.map.distance(point, next))}`);
}
});
});
} else if (layer instanceof L.Polyline) {
const lines = lineLatLngGroups(layer.getLatLngs());
details.push(`Lunghezza: ${formatDistance(lines.reduce((sum, line) => sum + latLngPathLength(line, false), 0))}`);
} else if (typeof layer.getLatLng === "function") {
const point = layer.getLatLng();
details.push(`Coordinate: ${point.lat.toFixed(6)}, ${point.lng.toFixed(6)}`);
}
return details;
}
function refreshLayerManager() {
updateFiducialDownloadButton();
const container = document.getElementById("layerManagerTree");
if (!container || !lmeState.map) {
return;
}
captureLayerManagerExpansion(container);
const scrollHost = container.closest(".map-layer-panel");
const scrollTop = scrollHost?.scrollTop || 0;
applyStoredLayerManagerVisibility();
container.innerHTML = "";
lmeState.managerRecords = new Map();
container.appendChild(renderLayerSelectionToolbar());
const categoriesByKey = new Map(layerManagerCategories().map(category => [category.key, category]));
lmeState.layerOrder.forEach(categoryKey => {
const category = categoriesByKey.get(categoryKey);
if (!category) {
return;
}
const records = managedRecordsForCategory(category);
if (records.length) {
lmeState.managerRemovedCategories.delete(category.key);
} else if (lmeState.managerRemovedCategories.has(category.key)) {
return;
}
const savedOrder = lmeState.managerObjectOrder[category.key] || [];
const positions = new Map(savedOrder.map((id, index) => [id, index]));
records.sort((left, right) =>
(positions.get(left.id) ?? Number.MAX_SAFE_INTEGER) - (positions.get(right.id) ?? Number.MAX_SAFE_INTEGER));
records.forEach(record => lmeState.managerRecords.set(record.id, record));
const rendered = renderLayerManagerCategory(category, records);
const search = lmeState.managerSearchText.trim().toLocaleLowerCase("it");
if (search && !records.some((record, index) => `${managedObjectLabel(record, index)} ${record.source}`.toLocaleLowerCase("it").includes(search))
&& !managedCategoryName(category, records).toLocaleLowerCase("it").includes(search)) {
rendered.hidden = true;
}
container.appendChild(rendered);
});
applyManagedVisualOrder(container);
bindLayerManagerDrag(container);
bindLayerManagerMoveButtons(container);
if (lmeState.managerHighlightedObjectId) {
window.requestAnimationFrame(() => revealManagedRecordInLayerManager(lmeState.managerHighlightedObjectId));
}
if (scrollHost) {
scrollHost.scrollTop = scrollTop;
window.requestAnimationFrame(() => {
scrollHost.scrollTop = scrollTop;
});
}
if (selectionHasObjects()) renderSelectedManagerHighlight();
}
function renderLayerManagerCategory(category, records) {
const group = document.createElement("details");
group.className = "layer-manager-group";
group.classList.toggle("layer-manager-selection-path", records.some(record =>
record.id === lmeState.managerHighlightedObjectId || lmeState.managerSelectedObjectIds.has(record.id)));
group.dataset.dragType = "category";
group.dataset.layerDepth = "0";
group.dataset.categoryKey = category.key;
applyLayerManagerPalette(group, `category:${category.key}`);
bindLayerManagerExpansion(group, `category:${category.key}`);
const categoryName = managedCategoryName(category, records);
const renameButton = managedCategoryCanRename(category)
? `<button type="button" class="layer-rename-button" aria-label="Rinomina gruppo" title="Rinomina">Rinomina</button>`
: "";
const removeButton = managedCategoryCanRemove(category, records)
? `<button type="button" class="layer-remove-button" aria-label="Rimuovi gruppo dal progetto" title="Rimuovi dal progetto">Rimuovi</button>`
: "";
const moveButton = records.length && managedCategoryCanMove(category.key)
? `<button type="button" class="layer-membership-button" aria-label="Sposta gruppo in un altro gruppo" title="Sposta nel gruppo">Sposta nel gruppo</button>`
: "";
const rowActions = layerManagerRowActions(renameButton, moveButton, removeButton);
group.classList.toggle("layer-manager-with-actions", Boolean(rowActions));
const visibleCount = records.filter(record => !lmeState.managerHiddenObjectIds.has(record.id)).length;
const categoryVisible = !lmeState.managerHiddenCategories.has(category.key)
&& (!category.roots.length || category.roots.some(layer => lmeState.map.hasLayer(layer)));
const visible = categoryVisible;
const categoryLocked = records.length > 0 && records.every(record => Boolean(record.layer?.feature?.properties?._lmeLocked));
group.innerHTML = `<summary>${layerExpandButton(group.open, categoryName)}<button type="button" class="layer-drag-handle" aria-label="Trascina gruppo">☰</button><input type="checkbox" ${visible ? "checked" : ""} aria-label="Mostra ${escapeHtml(categoryName)}"><span class="layer-manager-title">${escapeHtml(categoryName)}</span><button type="button" class="layer-inline-lock ${categoryLocked ? "is-locked" : ""}" aria-label="${categoryLocked ? "Sblocca" : "Blocca"} gruppo" title="${categoryLocked ? "Sblocca" : "Blocca"}">${categoryLocked ? "🔒" : "🔓"}</button>${rowActions}${layerManagerMoveButtons("gruppo")}<span class="layer-manager-count">${records.length}</span></summary><div class="layer-manager-children"></div>`;
bindLayerManagerToggleButton(group);
const checkbox = group.querySelector("input");
checkbox.indeterminate = categoryVisible && records.length > 0 && visibleCount < records.length;
checkbox.addEventListener("click", event => event.stopPropagation());
checkbox.addEventListener("change", () => setManagedCategoryVisible(category, checkbox.checked));
group.querySelector(".layer-inline-lock")?.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
updateManagedProperties(records, properties => { properties._lmeLocked = !categoryLocked; });
});
group.querySelector(".layer-rename-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
renameManagedCategory(category, records);
});
group.querySelector(".layer-remove-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
removeManagedRecords(records, `il gruppo '${categoryName}'`);
});
group.querySelector(".layer-membership-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
showMoveManagedRecordsDialog(records, `il gruppo '${categoryName}'`);
});
bindManagedParentHighlight(group, `category:${category.key}`, records);
const children = group.querySelector(".layer-manager-children");
if (records.length) children.appendChild(renderManagedGroupTools(records));
if (category.blocks) {
const blocks = new Map();
records.forEach(record => {
const list = blocks.get(record.source) || [];
list.push(record);
blocks.set(record.source, list);
});
blocks.forEach((blockRecords, source) => children.appendChild(renderLayerManagerBlock(category, source, blockRecords)));
} else {
records.forEach((record, index) => children.appendChild(renderLayerManagerObject(record, index)));
}
return group;
}
function renderLayerManagerBlock(category, source, records) {
const block = document.createElement("details");
block.className = "layer-manager-block";
block.classList.toggle("layer-manager-selection-path", records.some(record =>
record.id === lmeState.managerHighlightedObjectId || lmeState.managerSelectedObjectIds.has(record.id)));
block.dataset.dragType = "block";
block.dataset.layerDepth = "1";
block.dataset.categoryKey = category.key;
block.dataset.blockKey = source;
applyLayerManagerPalette(block, `block:${category.key}:${source}`);
bindLayerManagerExpansion(block, `block:${category.key}:${source}`);
const visibleCount = records.filter(record => !lmeState.managerHiddenObjectIds.has(record.id)).length;
const visible = visibleCount === records.length;
const blockName = managedBlockName(source, records);
const rowActions = layerManagerRowActions(
`<button type="button" class="layer-rename-button" aria-label="Rinomina gruppo" title="Rinomina">Rinomina</button>`,
managedCategoryCanMove(category.key)
? `<button type="button" class="layer-membership-button" aria-label="Sposta gruppo in un altro gruppo" title="Sposta nel gruppo">Sposta nel gruppo</button>`
: "",
managedObjectCanRemove(records[0])
? `<button type="button" class="layer-remove-button" aria-label="Rimuovi gruppo dal progetto" title="Rimuovi dal progetto">Rimuovi</button>`
: "");
block.classList.add("layer-manager-with-actions");
const blockLocked = records.length > 0 && records.every(record => Boolean(record.layer?.feature?.properties?._lmeLocked));
block.innerHTML = `<summary>${layerExpandButton(block.open, blockName)}<button type="button" class="layer-drag-handle" aria-label="Trascina blocco">☰</button><input type="checkbox" ${visible ? "checked" : ""} aria-label="Mostra ${escapeHtml(blockName)}"><span class="layer-manager-title">${escapeHtml(blockName)}</span><button type="button" class="layer-inline-lock ${blockLocked ? "is-locked" : ""}" aria-label="${blockLocked ? "Sblocca" : "Blocca"} gruppo" title="${blockLocked ? "Sblocca" : "Blocca"}">${blockLocked ? "🔒" : "🔓"}</button>${rowActions}${layerManagerMoveButtons("blocco")}<span class="layer-manager-count">${records.length}</span></summary><div class="layer-manager-children"></div>`;
bindLayerManagerToggleButton(block);
const checkbox = block.querySelector("input");
checkbox.indeterminate = visibleCount > 0 && !visible;
checkbox.addEventListener("click", event => event.stopPropagation());
checkbox.addEventListener("change", () => {
records.forEach(record => setManagedObjectVisible(record, checkbox.checked, false));
refreshLayerManager();
});
block.querySelector(".layer-inline-lock")?.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
updateManagedProperties(records, properties => { properties._lmeLocked = !blockLocked; });
});
block.querySelector(".layer-rename-button").addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
renameManagedBlock(category, source, records);
});
block.querySelector(".layer-remove-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
removeManagedRecords(records, `il gruppo '${blockName}'`);
});
block.querySelector(".layer-membership-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
showMoveManagedRecordsDialog(records, `il gruppo '${blockName}'`);
});
bindManagedParentHighlight(block, `block:${category.key}:${source}`, records);
const children = block.querySelector(".layer-manager-children");
children.appendChild(renderManagedGroupTools(records));
records.forEach((record, index) => children.appendChild(renderLayerManagerObject(record, index)));
return block;
}
function renderLayerManagerObject(record, index) {
const object = document.createElement("details");
object.className = "layer-manager-object";
if (record.id === lmeState.managerHighlightedObjectId || lmeState.managerSelectedObjectIds.has(record.id)) {
object.classList.add("layer-manager-object-selected");
}
object.dataset.dragType = "object";
object.dataset.layerDepth = "2";
object.dataset.objectId = record.id;
bindLayerManagerExpansion(object, `object:${record.id}`);
const visible = !lmeState.managerHiddenObjectIds.has(record.id);
const renameButton = managedObjectCanRename(record)
? `<button type="button" class="layer-rename-button" aria-label="Rinomina oggetto" title="Rinomina">Rinomina</button>`
: "";
const removeButton = managedObjectCanRemove(record)
? `<button type="button" class="layer-remove-button" aria-label="Rimuovi oggetto dal progetto" title="Rimuovi dal progetto">Rimuovi</button>`
: "";
const moveButton = managedCategoryCanMove(record.categoryKey)
? `<button type="button" class="layer-membership-button" aria-label="Sposta oggetto in un altro gruppo" title="Sposta nel gruppo">Sposta nel gruppo</button>`
: "";
const rowActions = layerManagerRowActions(renameButton, moveButton, removeButton);
object.classList.toggle("layer-manager-with-actions", Boolean(rowActions));
const objectName = managedObjectLabel(record, index);
const locked = Boolean(record.layer?.feature?.properties?._lmeLocked);
object.innerHTML = `<summary>${layerExpandButton(object.open, objectName)}<button type="button" class="layer-drag-handle" aria-label="Trascina oggetto">☰</button><input type="checkbox" ${visible ? "checked" : ""} aria-label="Mostra oggetto"><span class="layer-manager-title">${escapeHtml(objectName)}</span><button type="button" class="layer-inline-lock ${locked ? "is-locked" : ""}" aria-label="${locked ? "Sblocca" : "Blocca"} oggetto" title="${locked ? "Sblocca" : "Blocca"}">${locked ? "🔒" : "🔓"}</button>${rowActions}${layerManagerMoveButtons("oggetto")}</summary><div class="layer-manager-details"></div>`;
bindLayerManagerToggleButton(object);
const checkbox = object.querySelector("input");
checkbox.addEventListener("click", event => event.stopPropagation());
checkbox.addEventListener("change", () => setManagedObjectVisible(record, checkbox.checked, true));
object.querySelector(".layer-rename-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
renameManagedObject(record, index);
});
object.querySelector(".layer-remove-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
removeManagedRecords([record], `'${objectName}'`);
});
object.querySelector(".layer-membership-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
showMoveManagedRecordsDialog([record], `'${objectName}'`);
});
object.querySelector(".layer-inline-lock")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
updateManagedProperties([record], value => { value._lmeLocked = !locked; });
});
object.querySelector(".layer-manager-title").addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
highlightManagedObject(record);
});
const details = object.querySelector(".layer-manager-details");
details.appendChild(renderLayerManagerVisibilityOption(
geometryVisibilityLabel(record.layer),
visible,
checked => setManagedObjectVisible(record, checked, true)));
if (supportsManagedMeasurements(record)) {
managedMeasurementKinds(record).forEach(kind => {
const measurementsVisible = managedMeasurementsGloballyVisible(record.categoryKey)
&& !managedMeasurementHiddenSet(kind.key).has(record.id);
details.appendChild(renderLayerManagerVisibilityOption(
kind.label,
measurementsVisible,
checked => setManagedMeasurementKindVisible(record, kind.key, checked)));
});
}
const rows = managedObjectDetails(record.layer);
const info = document.createElement("div");
info.className = "layer-manager-info";
info.innerHTML = rows.length
? rows.map(value => `<span>${escapeHtml(value)}</span>`).join("")
: "<span>Nessuna misura disponibile</span>";
details.appendChild(info);
details.appendChild(renderManagedObjectTools(record));
return object;
}
function renderLayerSelectionToolbar() {
const selectedCount = lmeState.managerSelectedObjectIds.size;
const toolbar = document.createElement("section");
toolbar.className = "layer-selection-toolbar";
toolbar.innerHTML = `<label>Cerca oggetti<input type="search" value="${escapeHtml(lmeState.managerSearchText)}" placeholder="Nome o gruppo"></label>
<div class="layer-selection-summary"><strong>${selectedCount} selezionati</strong><button type="button" data-action="clear" title="Toglie la selezione senza eliminare oggetti">Cancella selezione</button></div>
<div class="layer-batch-actions">
<button type="button" data-action="duplicate">Duplica</button><button type="button" data-action="move">Sposta geometria</button>
<button type="button" data-action="buffer">Buffer</button><button type="button" data-action="union">Unisci</button>
<button type="button" data-action="split">Dividi</button><button type="button" data-action="delete" class="danger-command">Rimuovi</button>
</div>`;
const search = toolbar.querySelector("input");
search.addEventListener("input", () => {
const selectionStart = search.selectionStart ?? search.value.length;
const selectionEnd = search.selectionEnd ?? selectionStart;
lmeState.managerSearchText = search.value;
refreshLayerManager();
const replacement = document.querySelector(".layer-selection-toolbar input");
if (replacement) {
replacement.focus({ preventScroll: true });
replacement.setSelectionRange(selectionStart, selectionEnd);
}
});
toolbar.querySelectorAll("button").forEach(button => button.addEventListener("click", () => {
const records = [...lmeState.managerSelectedObjectIds]
.map(id => lmeState.managerRecords?.get(id)).filter(Boolean);
if (button.dataset.action === "clear") {
clearManagedSelection();
} else if (!records.length) {
setInfo("Seleziona almeno un oggetto dalle sue opzioni.");
} else if (button.dataset.action === "duplicate") duplicateManagedRecords(records);
else if (button.dataset.action === "move") moveManagedRecords(records);
else if (button.dataset.action === "buffer") bufferManagedRecords(records);
else if (button.dataset.action === "union") unionManagedRecords(records);
else if (button.dataset.action === "split") splitManagedRecords(records);
else if (button.dataset.action === "delete") removeManagedRecords(records, "gli oggetti selezionati");
}));
return toolbar;
}
function renderManagedGroupTools(records) {
const tools = document.createElement("div");
tools.className = "layer-group-tools";
const firstLayer = records[0]?.layer;
const properties = firstLayer?.feature?.properties || {};
const lineColor = normalizedColor(properties._lmeColor || firstLayer?.options?.color, "#16806a");
const fillColor = normalizedColor(properties._lmeFillColor || firstLayer?.options?.fillColor, lineColor);
const selectionColor = normalizedColor(properties._lmeSelectionColor, "#d500f9");
tools.innerHTML = `<strong>Azioni gruppo</strong><span class="layer-group-totals">${escapeHtml(managedGroupTotals(records))}</span><div class="layer-style-controls">
${managedColorEditorMarkup("color", "Colore linea", lineColor)}
${managedColorEditorMarkup("fillColor", "Colore riempimento", fillColor)}
${managedColorEditorMarkup("selectionColor", "Colore selezione", selectionColor)}
<label>Spessore<input type="range" data-field="weight" min="1" max="10" step="1" value="${Number(properties._lmeWeight) || Number(firstLayer?.options?.weight) || 3}"></label>
<label>Opacita<input type="range" data-field="opacity" min="0.1" max="1" step="0.1" value="${Number.isFinite(Number(properties._lmeOpacity)) ? Number(properties._lmeOpacity) : Number(firstLayer?.options?.opacity) || 1}"></label></div>
<div class="layer-compact-actions"><button type="button" data-group-action="select" ${lmeState.selectionMode === "off" ? "disabled" : ""}>Seleziona tutti</button><button type="button" data-group-action="lock">Blocca/sblocca</button><button type="button" data-group-action="labels">Etichette</button></div>`;
const applyCurrentStyle = () => {
applyManagedStyle(records, {
color: tools.querySelector("[data-color-picker='color']").value,
fillColor: tools.querySelector("[data-color-picker='fillColor']").value,
selectionColor: tools.querySelector("[data-color-picker='selectionColor']").value,
weight: Number(tools.querySelector("[data-field='weight']").value),
opacity: Number(tools.querySelector("[data-field='opacity']").value)
});
};
bindManagedColorEditors(tools, applyCurrentStyle);
tools.querySelectorAll("input[type='range']").forEach(input => input.addEventListener("change", applyCurrentStyle));
tools.querySelector("[data-group-action='select']").addEventListener("click", () => {
if (lmeState.selectionMode === "off") {
setInfo("Selezione disattivata. Scegli Singola o Multipla dalla barra mappa.");
return;
}
if (lmeState.selectionMode === "single") {
selectManagedRecord(records[0]);
return;
}
records.forEach(record => lmeState.managerSelectedObjectIds.add(record.id));
refreshLayerManager();
refreshMultipleMapSelection(records.at(-1));
setInfo(`${lmeState.managerSelectedObjectIds.size} oggetti selezionati.`);
});
tools.querySelector("[data-group-action='lock']").addEventListener("click", () => {
const lock = !records.every(record => Boolean(record.layer?.feature?.properties?._lmeLocked));
updateManagedProperties(records, properties => { properties._lmeLocked = lock; });
setInfo(lock ? "Gruppo bloccato." : "Gruppo sbloccato.");
});
tools.querySelector("[data-group-action='labels']").addEventListener("click", () => {
const visible = !records.every(record => Boolean(record.layer?.feature?.properties?._lmeLabelVisible));
updateManagedProperties(records, properties => { properties._lmeLabelVisible = visible; });
});
return tools;
}
function managedGroupTotals(records) {
let length = 0; let area = 0;
records.forEach(record => {
const layer = record.layer;
if (layer instanceof L.Circle) { area += Math.PI * layer.getRadius() ** 2; length += 2 * Math.PI * layer.getRadius(); }
else if (layer instanceof L.Polygon) polygonLatLngGroups(layer.getLatLngs()).forEach(polygon => {
area += Math.abs(polygon.reduce((sum, ring, index) => sum + (index ? -1 : 1) * Math.abs(L.GeometryUtil.geodesicArea(ring)), 0));
length += latLngPathLength(polygon[0] || [], true);
});
else if (layer instanceof L.Polyline) length += lineLatLngGroups(layer.getLatLngs()).reduce((sum, line) => sum + latLngPathLength(line, false), 0);
});
const values = []; if (area) values.push(`Area ${formatArea(area)}`); if (length) values.push(`Sviluppo ${formatDistance(length)}`);
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 managedNotesPopupMarkup(properties) {
const notes = managedNotes(properties);
return notes.length
? `<div class="lme-popup-notes"><strong>Note</strong>${notes.map(note => `<p>${escapeHtml(note.text).replaceAll("\n", "<br>")}</p>`).join("")}</div>`
: "";
}
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) => `<article class="managed-note" data-note-id="${escapeHtml(note.id)}">
<header><strong>Nota ${index + 1}</strong><time>${escapeHtml(managedNoteDate(note))}</time></header>
<p>${escapeHtml(note.text).replaceAll("\n", "<br>")}</p>
${canEdit ? `<div class="managed-note-actions"><button type="button" data-edit-note="${escapeHtml(note.id)}">Modifica</button><button type="button" class="danger-command" data-delete-note="${escapeHtml(note.id)}">Elimina</button></div>` : ""}
</article>`).join("");
const editor = canEdit ? `<form class="managed-note-editor">
<label>Nuova nota<textarea name="note" rows="4" maxlength="1000" required placeholder="Scrivi una nuova nota tecnica"></textarea></label>
<button type="submit" class="primary-command">Aggiungi nota</button>
</form>` : `<p class="managed-note-pro-message">${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."}</p>`;
const dialog = showAppDialog(title, `<div class="managed-notes-list">${list || "<p>Nessuna nota presente.</p>"}</div>${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);
const locked = Boolean(properties._lmeLocked);
const tools = document.createElement("section");
tools.className = "layer-object-tools";
const lineColor = normalizedColor(properties._lmeColor || record.layer?.options?.color, "#f97316");
const fillColor = normalizedColor(properties._lmeFillColor || record.layer?.options?.fillColor, lineColor);
const selectionColor = normalizedColor(properties._lmeSelectionColor, "#d500f9");
tools.innerHTML = `<label class="layer-manager-suboption"><input type="checkbox" data-field="selected" ${selected ? "checked" : ""} ${lmeState.selectionMode === "off" ? "disabled" : ""}><span>Selezionato (${escapeHtml(selectionModeLabel().toLocaleLowerCase("it"))})</span></label>
<label class="layer-manager-suboption"><input type="checkbox" data-field="locked" ${locked ? "checked" : ""}><span>Blocca modifiche e rimozione</span></label>
<label class="layer-manager-suboption"><input type="checkbox" data-field="label" ${properties._lmeLabelVisible ? "checked" : ""}><span>Etichetta permanente sulla mappa</span></label>
<div class="layer-style-controls">${managedColorEditorMarkup("color", "Colore linea", lineColor)}
${managedColorEditorMarkup("fillColor", "Colore riempimento", fillColor)}
${managedColorEditorMarkup("selectionColor", "Colore selezione", selectionColor)}
<label>Spessore<input type="range" data-field="weight" min="1" max="10" step="1" value="${Number(properties._lmeWeight) || 3}"></label>
<label>Opacita<input type="range" data-field="opacity" min="0.1" max="1" step="0.1" value="${Number(properties._lmeOpacity) || 1}"></label>
<label>Riempimento %<input type="range" data-field="fillOpacity" min="0" max="0.8" step="0.05" value="${Number.isFinite(Number(properties._lmeFillOpacity)) ? Number(properties._lmeFillOpacity) : 0.2}"></label>
<label>Tratto<select data-field="dash"><option value="">Continuo</option><option value="8 6" ${properties._lmeDash === "8 6" ? "selected" : ""}>Tratteggiato</option><option value="2 6" ${properties._lmeDash === "2 6" ? "selected" : ""}>Puntinato</option></select></label></div>
<div class="managed-notes-command"><button type="button" data-action="notes">Visualizza note (${managedNotes(properties).length})</button><small>Le note multiple sono modificabili con LME Pro.</small></div>
<label class="layer-photo-picker">Foto<input type="file" data-field="photo" accept="image/*"></label>
${properties._lmePhoto ? `<img class="layer-photo-preview" src="${properties._lmePhoto}" alt="Foto allegata"><button type="button" data-action="remove-photo">Rimuovi foto</button>` : ""}
<div class="layer-compact-actions"><button type="button" data-action="attributes">Attributi</button><button type="button" data-action="duplicate">Duplica</button><button type="button" data-action="numeric">Coordinate</button><button type="button" data-action="inspect">WGS84 / UTM</button><button type="button" data-action="move">Sposta geometria</button><button type="button" data-action="buffer">Buffer</button></div>`;
tools.querySelector("[data-field='selected']").addEventListener("change", event => {
if (lmeState.selectionMode === "off") {
event.target.checked = lmeState.managerSelectedObjectIds.has(record.id);
setInfo("Selezione disattivata. Scegli Singola o Multipla dalla barra mappa.");
return;
}
if (event.target.checked) {
selectManagedRecord(record);
} else {
lmeState.managerSelectedObjectIds.delete(record.id);
refreshLayerManager();
refreshMultipleMapSelection();
}
});
tools.querySelector("[data-field='locked']").addEventListener("change", event => updateManagedProperties([record], value => { value._lmeLocked = event.target.checked; }));
tools.querySelector("[data-field='label']").addEventListener("change", event => updateManagedProperties([record], value => { value._lmeLabelVisible = event.target.checked; }));
const applyCurrentStyle = () => applyManagedStyle([record], {
color: tools.querySelector("[data-color-picker='color']").value,
fillColor: tools.querySelector("[data-color-picker='fillColor']").value,
selectionColor: tools.querySelector("[data-color-picker='selectionColor']").value,
weight: Number(tools.querySelector("[data-field='weight']").value),
opacity: Number(tools.querySelector("[data-field='opacity']").value),
fillOpacity: Number(tools.querySelector("[data-field='fillOpacity']").value),
dash: tools.querySelector("[data-field='dash']").value
});
bindManagedColorEditors(tools, applyCurrentStyle);
tools.querySelectorAll(".layer-style-controls input[type='range'],.layer-style-controls select").forEach(input => input.addEventListener("change", applyCurrentStyle));
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; }
const dataUrl = await fileToDataUrl(file);
updateManagedProperties([record], value => { value._lmePhoto = dataUrl; });
});
tools.querySelector("[data-action='remove-photo']")?.addEventListener("click", () => updateManagedProperties([record], value => { delete value._lmePhoto; }));
tools.querySelector("[data-action='attributes']").addEventListener("click", () => editManagedAttributes(record));
tools.querySelector("[data-action='duplicate']").addEventListener("click", () => duplicateManagedRecords([record]));
tools.querySelector("[data-action='numeric']").addEventListener("click", () => numericEditManagedRecord(record));
tools.querySelector("[data-action='inspect']").addEventListener("click", () => showCoordinateInspectorForRecord(record));
tools.querySelector("[data-action='move']").addEventListener("click", () => moveManagedRecords([record]));
tools.querySelector("[data-action='buffer']").addEventListener("click", () => bufferManagedRecords([record]));
return tools;
}
function normalizedColor(value, fallback) {
return /^#[0-9a-f]{6}$/i.test(String(value || "")) ? value : fallback;
}
function managedColorEditorMarkup(field, label, color) {
const formats = managedColorFormats(color);
return `<section class="layer-color-editor" data-color-editor="${field}">
<header><strong>${escapeHtml(label)}</strong><input type="color" data-color-picker="${field}" value="${formats.hex}" aria-label="Seleziona ${escapeHtml(label.toLocaleLowerCase("it"))}"><button type="button" data-color-eyedropper title="Preleva un colore dallo schermo">Preleva</button></header>
<div class="layer-color-formats">
<label>HEX<input type="text" data-color-format="hex" value="${formats.hex}" maxlength="7" spellcheck="false"></label>
<label>RGB<input type="text" data-color-format="rgb" value="${formats.rgb}" spellcheck="false"></label>
<label>HSL<input type="text" data-color-format="hsl" value="${formats.hsl}" spellcheck="false"></label>
</div>
<button type="button" class="layer-color-copy" data-color-copy>Copia HEX</button>
</section>`;
}
function bindManagedColorEditors(container, onChanged) {
container.querySelectorAll("[data-color-editor]").forEach(editor => {
const picker = editor.querySelector("[data-color-picker]");
const formatInputs = [...editor.querySelectorAll("[data-color-format]")];
const sync = (color, notify) => {
const formats = managedColorFormats(color);
picker.value = formats.hex;
formatInputs.forEach(input => { input.value = formats[input.dataset.colorFormat]; });
if (notify) onChanged();
};
picker.addEventListener("change", () => sync(picker.value, true));
formatInputs.forEach(input => input.addEventListener("change", () => {
const parsed = parseManagedColor(input.value);
if (!parsed) {
sync(picker.value, false);
setInfo("Colore non valido. Usa HEX, rgb(r, g, b) oppure hsl(h, s%, l%).");
return;
}
sync(parsed, true);
}));
const eyedropper = editor.querySelector("[data-color-eyedropper]");
if (typeof window.EyeDropper !== "function") {
eyedropper.hidden = true;
} else {
eyedropper.addEventListener("click", async () => {
try {
const result = await new window.EyeDropper().open();
if (result?.sRGBHex) sync(result.sRGBHex, true);
} catch (error) {
if (error?.name !== "AbortError") setInfo("Contagocce non disponibile su questo dispositivo.");
}
});
}
editor.querySelector("[data-color-copy]").addEventListener("click", async () => {
const value = picker.value.toUpperCase();
try {
await navigator.clipboard.writeText(value);
setInfo(`Colore ${value} copiato. Puoi incollarlo nel campo HEX di un altro oggetto.`);
} catch {
const hexInput = editor.querySelector("[data-color-format='hex']");
hexInput.focus();
hexInput.select();
setInfo(`Selezionato ${value}: usa il comando Copia del dispositivo.`);
}
});
});
}
function parseManagedColor(value) {
const text = String(value || "").trim();
const shortHex = text.match(/^#([0-9a-f]{3})$/i);
if (shortHex) return `#${[...shortHex[1]].map(part => part + part).join("")}`.toUpperCase();
if (/^#[0-9a-f]{6}$/i.test(text)) return text.toUpperCase();
const rgb = text.match(/^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})(?:\s*[,/]\s*[\d.]+)?\s*\)$/i);
if (rgb) {
const values = rgb.slice(1, 4).map(Number);
if (values.every(channel => channel >= 0 && channel <= 255)) return managedRgbToHex(...values);
}
const hsl = text.match(/^hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[, ]\s*([\d.]+)%\s*[, ]\s*([\d.]+)%(?:\s*[,/]\s*[\d.]+)?\s*\)$/i);
if (hsl) {
const saturation = Number(hsl[2]); const lightness = Number(hsl[3]);
if (saturation >= 0 && saturation <= 100 && lightness >= 0 && lightness <= 100) {
return managedRgbToHex(...managedHslToRgb(Number(hsl[1]), saturation, lightness));
}
}
return null;
}
function managedColorFormats(color) {
const hex = normalizedColor(color, "#000000").toUpperCase();
const [red, green, blue] = [1, 3, 5].map(index => parseInt(hex.slice(index, index + 2), 16));
const redUnit = red / 255; const greenUnit = green / 255; const blueUnit = blue / 255;
const max = Math.max(redUnit, greenUnit, blueUnit); const min = Math.min(redUnit, greenUnit, blueUnit);
const lightness = (max + min) / 2; const delta = max - min;
let hue = 0; let saturation = 0;
if (delta) {
saturation = delta / (1 - Math.abs(2 * lightness - 1));
if (max === redUnit) hue = 60 * (((greenUnit - blueUnit) / delta) % 6);
else if (max === greenUnit) hue = 60 * ((blueUnit - redUnit) / delta + 2);
else hue = 60 * ((redUnit - greenUnit) / delta + 4);
}
if (hue < 0) hue += 360;
return {
hex,
rgb: `rgb(${red}, ${green}, ${blue})`,
hsl: `hsl(${managedColorDecimal(hue)}, ${managedColorDecimal(saturation * 100)}%, ${managedColorDecimal(lightness * 100)}%)`
};
}
function managedColorDecimal(value) {
return String(Number(value.toFixed(2)));
}
function managedRgbToHex(red, green, blue) {
return `#${[red, green, blue].map(channel => Math.round(channel).toString(16).padStart(2, "0")).join("")}`.toUpperCase();
}
function managedHslToRgb(hue, saturation, lightness) {
const normalizedHue = ((hue % 360) + 360) % 360;
const saturationUnit = saturation / 100; const lightnessUnit = lightness / 100;
const chroma = (1 - Math.abs(2 * lightnessUnit - 1)) * saturationUnit;
const section = normalizedHue / 60; const intermediate = chroma * (1 - Math.abs(section % 2 - 1));
const values = section < 1 ? [chroma, intermediate, 0]
: section < 2 ? [intermediate, chroma, 0]
: section < 3 ? [0, chroma, intermediate]
: section < 4 ? [0, intermediate, chroma]
: section < 5 ? [intermediate, 0, chroma]
: [chroma, 0, intermediate];
const offset = lightnessUnit - chroma / 2;
return values.map(channel => Math.round((channel + offset) * 255));
}
function editableManagedMetadataEntries(record) {
const properties = record?.layer?.feature?.properties || {};
const reserved = new Set(["name", "Name", "label", "LABEL"]);
return Object.entries(properties)
.filter(([key, value]) => !key.startsWith("_lme") && !reserved.has(key) && ["string", "number", "boolean"].includes(typeof value));
}
function editManagedAttributes(record) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
const reserved = new Set(["name", "Name", "label", "LABEL"]);
const entries = editableManagedMetadataEntries(record);
const sourceRecords = layerManagerCategories()
.flatMap(category => managedRecordsForCategory(category))
.filter(item => item.id !== record.id);
const sourceOptions = sourceRecords.map((item, index) =>
`<option value="${escapeHtml(item.id)}">${escapeHtml(managedObjectLabel(item, index))} - ${escapeHtml(item.source)}</option>`).join("");
const dialog = showAppDialog("Metadati oggetto", `<div class="attribute-editor"><p>Ogni metadato viene salvato nel progetto e negli export GeoJSON, KML e KMZ.</p>
<section class="attribute-copy-panel"><strong>Copia da un altro oggetto</strong><label>Oggetto sorgente<select data-copy-source ${sourceRecords.length ? "" : "disabled"}><option value="">Seleziona un oggetto</option>${sourceOptions}</select></label><label class="toggle-line"><input type="checkbox" data-copy-values checked><span>Importa anche i valori</span></label><small>Disattiva il flag per importare soltanto i nomi dei metadati con valore vuoto.</small><button type="button" data-copy-attributes ${sourceRecords.length ? "" : "disabled"}>Copia da</button></section>
<div class="attribute-editor-rows"></div><button type="button" data-add-attribute>+ Aggiungi metadato</button><div class="dialog-command-row"><button type="button" data-cancel>Annulla</button><button type="button" class="primary-command" data-save-attributes>Salva metadati</button></div></div>`);
const rows = dialog.querySelector(".attribute-editor-rows");
const addRow = (key = "", value = "") => {
const row = document.createElement("div");
row.className = "attribute-editor-row";
row.innerHTML = `<label>Nome<input type="text" maxlength="60" value="${escapeHtml(key)}" placeholder="Es. proprietario"></label><label>Valore<textarea rows="3" maxlength="4000" placeholder="Testo anche su piu righe">${escapeHtml(String(value))}</textarea></label><button type="button" title="Rimuovi attributo" aria-label="Rimuovi attributo">×</button>`;
row.querySelector("button").addEventListener("click", () => row.remove());
rows.appendChild(row);
};
entries.forEach(([key, value]) => addRow(key, value));
if (!entries.length) addRow();
const sourceById = new Map(sourceRecords.map(item => [item.id, item]));
dialog.querySelector("[data-copy-attributes]")?.addEventListener("click", () => {
const source = sourceById.get(dialog.querySelector("[data-copy-source]").value);
if (!source) { setInfo("Seleziona l'oggetto da cui copiare i metadati."); return; }
const includeValues = dialog.querySelector("[data-copy-values]").checked;
const existingRows = new Map([...rows.querySelectorAll(".attribute-editor-row")]
.map(row => [row.querySelector("input").value.trim().toLocaleLowerCase("it"), row])
.filter(([key]) => key));
const sourceEntries = editableManagedMetadataEntries(source);
sourceEntries.forEach(([key, value]) => {
const existing = existingRows.get(key.toLocaleLowerCase("it"));
if (existing) {
if (includeValues) existing.querySelector("textarea").value = String(value);
} else {
addRow(key, includeValues ? value : "");
}
});
setInfo(`${sourceEntries.length} metadati copiati${includeValues ? " con i valori" : " senza valori"}.`);
});
dialog.querySelector("[data-add-attribute]").addEventListener("click", () => addRow());
dialog.querySelector("[data-cancel]").addEventListener("click", () => dialog.remove());
dialog.querySelector("[data-save-attributes]").addEventListener("click", () => {
const parsed = {};
rows.querySelectorAll(".attribute-editor-row").forEach(row => {
const key = row.querySelector("input").value.trim().replace(/[^a-zA-Z0-9_ -]/g, "_").slice(0, 60);
const value = row.querySelector("textarea").value.slice(0, 4000);
if (key) parsed[key] = value;
});
updateManagedProperties([record], target => {
Object.keys(target).filter(key => !key.startsWith("_lme") && !reserved.has(key)).forEach(key => delete target[key]);
Object.assign(target, parsed);
});
setInfo(`${Object.keys(parsed).length} attributi aggiornati.`);
dialog.remove();
});
}
function utmFromLatLng(latitude, longitude) {
const zone = Math.floor((longitude + 180) / 6) + 1;
const central = (zone - 1) * 6 - 180 + 3;
const a = 6378137; const eccSquared = 0.00669438; const k0 = 0.9996;
const latRad = latitude * Math.PI / 180; const lonRad = longitude * Math.PI / 180; const lonOriginRad = central * Math.PI / 180;
const eccPrimeSquared = eccSquared / (1 - eccSquared);
const n = a / Math.sqrt(1 - eccSquared * Math.sin(latRad) ** 2);
const t = Math.tan(latRad) ** 2; const c = eccPrimeSquared * Math.cos(latRad) ** 2;
const aa = Math.cos(latRad) * (lonRad - lonOriginRad);
const m = a * ((1 - eccSquared / 4 - 3 * eccSquared ** 2 / 64 - 5 * eccSquared ** 3 / 256) * latRad
- (3 * eccSquared / 8 + 3 * eccSquared ** 2 / 32 + 45 * eccSquared ** 3 / 1024) * Math.sin(2 * latRad)
+ (15 * eccSquared ** 2 / 256 + 45 * eccSquared ** 3 / 1024) * Math.sin(4 * latRad)
- (35 * eccSquared ** 3 / 3072) * Math.sin(6 * latRad));
const easting = k0 * n * (aa + (1 - t + c) * aa ** 3 / 6 + (5 - 18 * t + t ** 2 + 72 * c - 58 * eccPrimeSquared) * aa ** 5 / 120) + 500000;
let northing = k0 * (m + n * Math.tan(latRad) * (aa ** 2 / 2 + (5 - t + 9 * c + 4 * c ** 2) * aa ** 4 / 24
+ (61 - 58 * t + t ** 2 + 600 * c - 330 * eccPrimeSquared) * aa ** 6 / 720));
if (latitude < 0) northing += 10000000;
return { zone, band: latitude >= 0 ? "N" : "S", easting, northing };
}
function recordCenter(record) {
if (typeof record?.layer?.getBounds === "function" && record.layer.getBounds().isValid()) return record.layer.getBounds().getCenter();
return record?.layer?.getLatLng?.() || null;
}
function showCoordinateInspectorForRecord(record) {
const point = recordCenter(record);
if (!point) { setInfo("Coordinate non disponibili per questo oggetto."); return; }
showCoordinateDialog(point, managedObjectEditableName(record) || "Oggetto selezionato");
}
function showCoordinateInspector() {
const records = [...lmeState.managerSelectedObjectIds].map(id => lmeState.managerRecords?.get(id)).filter(Boolean);
const highlighted = lmeState.managerRecords?.get(lmeState.managerHighlightedObjectId);
const record = highlighted || records[0];
const point = record ? recordCenter(record) : lmeState.map.getCenter();
showCoordinateDialog(point, record ? managedObjectEditableName(record) || "Oggetto selezionato" : "Centro mappa");
}
function showCoordinateDialog(point, title) {
const utm = utmFromLatLng(point.lat, point.lng);
const text = `WGS84: ${point.lat.toFixed(7)}, ${point.lng.toFixed(7)}\nUTM ${utm.zone}${utm.band}: E ${utm.easting.toFixed(2)} m, N ${utm.northing.toFixed(2)} m`;
showAppDialog(title, `<div class="coordinate-result"><strong>WGS84</strong><span>${point.lat.toFixed(7)}, ${point.lng.toFixed(7)}</span><strong>UTM zona ${utm.zone}${utm.band}</strong><span>E ${utm.easting.toFixed(2)} m<br>N ${utm.northing.toFixed(2)} m</span><button type="button" data-copy-coordinate>Copia coordinate</button></div>`);
document.querySelector("[data-copy-coordinate]")?.addEventListener("click", async () => {
await navigator.clipboard?.writeText(text);
setInfo("Coordinate copiate.");
});
}
function showAppDialog(title, bodyHtml) {
document.getElementById("lmeAppDialog")?.remove();
const overlay = document.createElement("div");
overlay.id = "lmeAppDialog";
overlay.className = "lme-app-dialog";
overlay.innerHTML = `<section role="dialog" aria-modal="true"><header><strong>${escapeHtml(title)}</strong><button type="button" aria-label="Chiudi">×</button></header><div class="lme-app-dialog-body">${bodyHtml}</div></section>`;
overlay.querySelector("header button").addEventListener("click", () => overlay.remove());
overlay.addEventListener("click", event => { if (event.target === overlay) overlay.remove(); });
document.querySelector(".map-app")?.appendChild(overlay);
return overlay;
}
function showFormDialog(title, fields, submitLabel = "Salva") {
return new Promise(resolve => {
const fieldHtml = fields.map(field => {
const common = `name="${escapeHtml(field.name)}" ${field.required === false ? "" : "required"} ${field.maxLength ? `maxlength="${field.maxLength}"` : ""}`;
const control = field.type === "textarea"
? `<textarea ${common} rows="${field.rows || 5}">${escapeHtml(field.value ?? "")}</textarea>`
: `<input ${common} type="${field.type || "text"}" value="${escapeHtml(field.value ?? "")}" ${field.step ? `step="${field.step}"` : ""}>`;
return `<label>${escapeHtml(field.label)}${control}${field.help ? `<small>${escapeHtml(field.help)}</small>` : ""}</label>`;
}).join("");
const dialog = showAppDialog(title, `<form class="lme-edit-form">${fieldHtml}<div class="dialog-command-row"><button type="button" data-cancel>Annulla</button><button type="submit" class="primary-command">${escapeHtml(submitLabel)}</button></div></form>`);
const form = dialog.querySelector("form");
let settled = false;
const finish = value => { if (settled) return; settled = true; resolve(value); dialog.remove(); };
dialog.querySelector("header button").addEventListener("click", () => finish(null), { capture: true });
dialog.querySelector("[data-cancel]").addEventListener("click", () => finish(null));
dialog.addEventListener("click", event => { if (event.target === dialog) finish(null); }, { capture: true });
form.addEventListener("submit", event => {
event.preventDefault();
const values = {};
fields.forEach(field => { values[field.name] = form.elements[field.name]?.value ?? ""; });
finish(values);
});
form.elements[fields[0]?.name]?.focus();
});
}
async function promptProjectName(currentName) {
const result = await showFormDialog("Rinomina progetto", [{
name: "name",
label: "Nuovo nome",
value: currentName || "",
maxLength: 80
}], "Rinomina");
return result?.name?.trim() || null;
}
function showObjectTable() {
const records = layerManagerCategories().flatMap(category => managedRecordsForCategory(category));
lmeState.managerRecords = new Map(records.map(record => [record.id, record]));
const body = records.map((record, index) => {
const properties = record.layer?.feature?.properties || {};
const details = managedObjectDetails(record.layer);
const selected = lmeState.managerSelectedObjectIds.has(record.id);
const locked = Boolean(properties._lmeLocked);
return `<tr class="${selected ? "object-table-selected" : ""}" data-object-id="${escapeHtml(record.id)}"><td><input type="checkbox" data-select="${escapeHtml(record.id)}" ${selected ? "checked" : ""} ${lmeState.selectionMode === "off" ? "disabled" : ""} aria-label="Seleziona ${escapeHtml(managedObjectLabel(record, index))}"></td><td><input type="checkbox" data-lock="${escapeHtml(record.id)}" ${locked ? "checked" : ""} ${lmeState.readOnly ? "disabled" : ""} aria-label="Blocca ${escapeHtml(managedObjectLabel(record, index))}"></td><td><button type="button" data-locate="${escapeHtml(record.id)}" title="Mostra sulla mappa" aria-label="Mostra sulla mappa">⌖</button></td><td><button type="button" data-attributes="${escapeHtml(record.id)}" title="Modifica metadati" aria-label="Modifica metadati di ${escapeHtml(managedObjectLabel(record, index))}" ${lmeState.readOnly ? "disabled" : ""}>✎</button></td><td><button type="button" class="object-notes-button" data-notes="${escapeHtml(record.id)}" title="Visualizza note">Note (${managedNotes(properties).length})</button></td><td>${escapeHtml(managedObjectLabel(record, index))}</td><td>${escapeHtml(managedCategoryName(layerManagerCategories().find(item => item.key === record.categoryKey) || { key: record.categoryKey, label: record.categoryKey }, [record]))}</td><td>${escapeHtml(properties.foglio || properties.FOGLIO || "")}</td><td>${escapeHtml(properties.particella || properties.PARTICELLA || properties.LABEL || "")}</td><td>${escapeHtml(details.slice(0, 2).join(" | "))}</td></tr>`;
}).join("");
const dialog = showAppDialog("Elenco oggetti e particelle", `<label class="object-table-search">Filtra<input type="search" placeholder="Nome, foglio, particella o gruppo"></label><div class="object-table-legend"><span>Modalita: <strong>${escapeHtml(selectionModeLabel())}</strong>. La selezione e condivisa con mappa e pannello Layer.</span><button type="button" data-table-clear title="Toglie la selezione senza eliminare oggetti">Cancella selezione</button><span>Il lucchetto impedisce modifiche e rimozioni; la matita modifica i metadati.</span><span>Note apre l'elenco completo; le modifiche richiedono LME Pro.</span></div><div class="object-table-wrap"><table><thead><tr><th>Selez.</th><th>Blocca</th><th>Mappa</th><th>Metadati</th><th>Note</th><th>Nome</th><th>Gruppo</th><th>Foglio</th><th>Particella</th><th>Misure</th></tr></thead><tbody>${body || "<tr><td colspan='10'>Nessun oggetto</td></tr>"}</tbody></table></div>`);
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");
dialog.querySelectorAll("tbody tr").forEach(row => { row.hidden = query && !row.textContent.toLocaleLowerCase("it").includes(query); });
});
dialog.querySelector("[data-table-clear]").addEventListener("click", () => {
clearManagedSelection();
dialog.querySelectorAll("[data-select]").forEach(candidate => {
candidate.checked = false;
candidate.closest("tr")?.classList.remove("object-table-selected");
});
});
dialog.querySelectorAll("[data-select]").forEach(input => input.addEventListener("change", () => {
const record = byId.get(input.dataset.select);
if (!record) return;
if (input.checked) {
selectManagedRecord(record);
} else {
lmeState.managerSelectedObjectIds.delete(record.id);
refreshLayerManager();
refreshMultipleMapSelection();
}
dialog.querySelectorAll("[data-select]").forEach(candidate => {
candidate.checked = lmeState.managerSelectedObjectIds.has(candidate.dataset.select);
candidate.closest("tr")?.classList.toggle("object-table-selected", candidate.checked);
});
setInfo(`${lmeState.managerSelectedObjectIds.size} oggetti selezionati.`);
}));
dialog.querySelectorAll("[data-lock]").forEach(input => input.addEventListener("change", () => {
const record = byId.get(input.dataset.lock);
if (!record) return;
updateManagedProperties([record], properties => { properties._lmeLocked = input.checked; });
setInfo(input.checked ? "Oggetto bloccato." : "Oggetto sbloccato.");
}));
dialog.querySelectorAll("[data-locate]").forEach(button => button.addEventListener("click", () => {
const record = byId.get(button.dataset.locate); if (!record) return;
dialog.remove();
if (lmeState.selectionMode !== "off") selectManagedRecord(record);
fitLayer(record.layer);
}));
dialog.querySelectorAll("[data-attributes]").forEach(button => button.addEventListener("click", () => {
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;
}
function showAuditLog() {
const rows = [...lmeState.auditLog].reverse().map(item => `<tr><td>${escapeHtml(new Date(item.at).toLocaleString("it-IT"))}</td><td>${escapeHtml(item.action || "Modifica mappa")}</td></tr>`).join("");
showAppDialog("Registro operazioni", `<div class="object-table-wrap"><table><thead><tr><th>Data e ora</th><th>Operazione</th></tr></thead><tbody>${rows || "<tr><td colspan='2'>Nessuna operazione registrata</td></tr>"}</tbody></table></div>`);
return lmeState.auditLog.length;
}
function updateManagedProperties(records, change) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
restoreDirectManagerHighlights();
rememberMapChange();
records.forEach(record => {
record.layer.feature ||= { type: "Feature", properties: {} };
record.layer.feature.properties ||= {};
change(record.layer.feature.properties);
applyStyleFromProperties(record.layer);
updateStoredFeaturePopup(record.layer);
});
refreshPermanentLabels();
refreshLayerManager();
finishMapChange();
}
function applyManagedStyle(records, style) {
updateManagedProperties(records, properties => {
properties._lmeColor = style.color;
properties._lmeFillColor = style.fillColor || style.color;
properties._lmeWeight = style.weight;
properties._lmeOpacity = style.opacity;
if (style.selectionColor !== undefined) properties._lmeSelectionColor = style.selectionColor;
if (style.fillOpacity !== undefined) properties._lmeFillOpacity = style.fillOpacity;
if (style.dash !== undefined) properties._lmeDash = style.dash;
});
}
function applyStyleFromProperties(layer) {
const properties = layer?.feature?.properties || {};
if (typeof layer?.setStyle === "function") {
lmeState.managerDirectHighlightStyles.delete(layer);
layer.setStyle({
color: properties._lmeColor || layer.options.color,
fillColor: properties._lmeFillColor || layer.options.fillColor,
weight: Number(properties._lmeWeight) || layer.options.weight,
opacity: Number.isFinite(Number(properties._lmeOpacity)) ? Number(properties._lmeOpacity) : layer.options.opacity,
fillOpacity: Number.isFinite(Number(properties._lmeFillOpacity)) ? Number(properties._lmeFillOpacity) : layer.options.fillOpacity,
dashArray: properties._lmeDash || null
});
}
}
function refreshPermanentLabels() {
lmeState.permanentLabels?.clearLayers();
layerManagerCategories().forEach(category => managedRecordsForCategory(category).forEach((record, index) => {
const properties = record.layer?.feature?.properties || {};
if (!properties._lmeLabelVisible || lmeState.managerHiddenObjectIds.has(record.id)) return;
const center = layerLabelPosition(record.layer); if (!center) return;
L.marker(center, { pane: "tooltipPane", interactive: false, icon: L.divIcon({ className: "lme-permanent-label", html: `<span>${escapeHtml(managedObjectLabel(record, index))}</span>`, iconSize: null }) }).addTo(lmeState.permanentLabels);
}));
}
function layerLabelPosition(layer) {
if (typeof layer?.getBounds === "function" && layer.getBounds().isValid()) return layer.getBounds().getCenter();
if (typeof layer?.getLatLng === "function") return layer.getLatLng();
return null;
}
function fileToDataUrl(file) {
return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); });
}
function addDrawingFeature(feature, select = false) {
let added = null;
L.geoJSON(feature, {
pane: "drawingsPane",
style: storedFeatureStyle,
pointToLayer: (item, latlng) => item?.properties?._lmeShape === "circle" && Number(item.properties._lmeRadius) > 0
? L.circle(latlng, { pane: "drawingsPane", renderer: lmeState.drawingsRenderer, radius: Number(item.properties._lmeRadius) })
: L.marker(latlng, { pane: "drawingsMarkerPane", draggable: false }),
onEachFeature: (item, layer) => {
annotateDrawingLayer(layer, item?.properties?._lmeShape);
applyStyleFromProperties(layer);
updateStoredFeaturePopup(layer);
lmeState.drawings.addLayer(layer);
added = layer;
}
});
if (select && added) selectDrawingLayer(added);
return added;
}
function storedFeatureStyle(feature) {
const properties = feature?.properties || {};
return {
...(properties._lmeComparisonResult ? { pane: "comparisonPane" } : { renderer: lmeState.drawingsRenderer }),
color: properties._lmeColor || "#f97316",
fillColor: properties._lmeFillColor || "#fb923c",
weight: Number(properties._lmeWeight) || 3,
opacity: Number.isFinite(Number(properties._lmeOpacity)) ? Number(properties._lmeOpacity) : 1,
fillOpacity: Number.isFinite(Number(properties._lmeFillOpacity)) ? Number(properties._lmeFillOpacity) : 0.2,
dashArray: properties._lmeDash || null
};
}
function duplicateManagedRecords(records) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) { setInfo("Sblocca gli oggetti da duplicare."); return; }
if (!canAddDrawingObjects(records.length)) return;
rememberMapChange();
records.forEach((record, index) => {
const feature = record.layer.toGeoJSON();
feature.properties = { ...(feature.properties || {}), name: `${managedObjectLabel(record, index)} - copia`, _lmeManagerId: undefined };
if (record.layer instanceof L.Circle) {
const point = turf.transformTranslate(feature, 1.5, 45, { units: "meters" });
point.properties._lmeShape = "circle";
point.properties._lmeRadius = record.layer.getRadius();
addDrawingFeature(point);
} else {
addDrawingFeature(turf.transformTranslate(feature, 1.5, 45, { units: "meters" }));
}
});
refreshDrawingMeasurements(); refreshLayerManager(); finishMapChange();
setInfo(`${records.length} ${records.length === 1 ? "oggetto duplicato" : "oggetti duplicati"}.`);
}
async function moveManagedRecords(records) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) { setInfo("Sblocca gli oggetti da spostare."); return; }
const result = await showFormDialog("Sposta oggetti", [
{ name: "north", label: "Metri verso nord (negativo verso sud)", type: "number", value: "0", step: "0.01" },
{ name: "east", label: "Metri verso est (negativo verso ovest)", type: "number", value: "0", step: "0.01" }
], "Sposta");
if (!result) return;
const north = Number(result.north);
if (!Number.isFinite(north)) return;
const east = Number(result.east);
if (!Number.isFinite(east) || (!north && !east)) return;
const distance = Math.hypot(north, east);
const bearing = Math.atan2(east, north) * 180 / Math.PI;
rememberMapChange();
records.forEach(record => {
const moved = turf.transformTranslate(record.layer.toGeoJSON(), distance, bearing, { units: "meters" });
replaceManagedLayer(record, moved);
});
finishManagedGeometryChange(`Spostati ${records.length} oggetti.`);
}
async function bufferManagedRecords(records) {
if (!requireProFeature("advancedGeometry")) return;
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
const result = await showFormDialog("Crea buffer", [{ name: "meters", label: "Distanza in metri", type: "number", value: "5", step: "0.01" }], "Crea buffer");
if (!result) return;
const meters = Number(result.meters);
if (!Number.isFinite(meters) || meters <= 0) return;
rememberMapChange();
let count = 0;
records.forEach((record, index) => {
const buffered = turf.buffer(record.layer.toGeoJSON(), meters, { units: "meters" });
if (!buffered) return;
buffered.properties = { ...(record.layer.feature?.properties || {}), name: `Buffer ${meters} m - ${managedObjectLabel(record, index)}`, _lmeShape: "polygon", _lmeManagerId: undefined };
addDrawingFeature(buffered); count++;
});
finishManagedGeometryChange(`Creati ${count} buffer da ${formatDistance(meters)}.`);
}
function unionManagedRecords(records) {
if (!requireProFeature("advancedGeometry")) return;
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
const polygons = records.filter(record => record.layer instanceof L.Polygon).map(record => record.layer.toGeoJSON());
if (polygons.length < 2) { setInfo("Per unire seleziona almeno due aree o poligoni."); return; }
try {
const merged = turf.union(turf.featureCollection(polygons));
if (!merged) throw new Error("le aree non producono un risultato");
rememberMapChange();
merged.properties = { ...(polygons[0].properties || {}), name: "Unione aree", _lmeShape: "polygon", _lmeManagerId: undefined };
addDrawingFeature(merged, true);
polygons.forEach((_feature, index) => removeRecordWithoutPrompt(records.filter(record => record.layer instanceof L.Polygon)[index]));
lmeState.managerSelectedObjectIds.clear();
finishManagedGeometryChange("Aree unite in un nuovo oggetto.");
} catch (error) { setInfo(`Unione non riuscita: ${error.message}`); }
}
function splitManagedRecords(records) {
if (!requireProFeature("advancedGeometry")) return;
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
const polygonRecord = records.find(record => record.layer instanceof L.Polygon);
const lineRecord = records.find(record => record.layer instanceof L.Polyline && !(record.layer instanceof L.Polygon));
if (!polygonRecord || !lineRecord) { setInfo("Per dividere seleziona un poligono e una linea di taglio."); return; }
try {
const cutter = turf.buffer(lineRecord.layer.toGeoJSON(), 0.02, { units: "meters" });
const split = turf.difference(turf.featureCollection([polygonRecord.layer.toGeoJSON(), cutter]));
const parts = split ? turf.flatten(split).features : [];
if (parts.length < 2) throw new Error("la linea deve attraversare completamente il poligono");
rememberMapChange();
parts.forEach((part, index) => {
part.properties = { ...(polygonRecord.layer.feature?.properties || {}), name: `${managedObjectLabel(polygonRecord, 0)} - parte ${index + 1}`, _lmeManagerId: undefined };
addDrawingFeature(part);
});
removeRecordWithoutPrompt(polygonRecord);
lmeState.managerSelectedObjectIds.clear();
finishManagedGeometryChange(`Poligono diviso in ${parts.length} parti.`);
} catch (error) { setInfo(`Divisione non riuscita: ${error.message}`); }
}
function selectedManagedRecords() {
const current = new Map(layerManagerCategories()
.flatMap(category => managedRecordsForCategory(category))
.map(record => [record.id, record]));
lmeState.managerRecords = current;
return [...lmeState.managerSelectedObjectIds].map(id => current.get(id)).filter(Boolean);
}
function createHoleFromSelected() {
if (!requireProFeature("advancedGeometry")) return false;
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
const polygons = selectedManagedRecords().filter(record => record.layer instanceof L.Polygon);
if (polygons.length !== 2) { showGeometryResult("Creazione foro", "Seleziona esattamente due poligoni: l'area esterna e l'area interna da escludere.", "warning"); return false; }
try {
const ordered = [...polygons].sort((left, right) => turf.area(right.layer.toGeoJSON()) - turf.area(left.layer.toGeoJSON()));
const outer = ordered[0].layer.toGeoJSON();
const inner = ordered[1].layer.toGeoJSON();
if (!turf.booleanWithin(inner, outer)) throw new Error("il poligono interno deve essere completamente contenuto nell'area esterna");
const result = turf.difference(turf.featureCollection([outer, inner]));
if (!result) throw new Error("nessun risultato geometrico");
rememberMapChange();
result.properties = { ...(outer.properties || {}), name: `${managedObjectLabel(ordered[0], 0)} con area esclusa`, _lmeManagerId: undefined };
addDrawingFeature(result, true);
ordered.forEach(removeRecordWithoutPrompt);
lmeState.managerSelectedObjectIds.clear();
finishManagedGeometryChange("Poligono con foro creato correttamente.");
showGeometryResult("Foro creato", "Il nuovo poligono contiene l'area interna esclusa. I due oggetti originali sono stati sostituiti.", "success");
return true;
} catch (error) { showGeometryResult("Creazione foro non riuscita", error.message, "error"); return false; }
}
function validateAndRepairSelected() {
if (!requireProFeature("advancedGeometry")) return "LME Pro richiesto";
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return "Progetto in sola lettura"; }
const records = selectedManagedRecords();
if (!records.length) { showGeometryResult("Controlla e ripara", "Seleziona uno o più oggetti dalla mappa, dal pannello Layer o dalla tabella oggetti.", "warning"); return "Nessuna selezione"; }
let valid = 0; let repaired = 0; let rejected = 0;
rememberMapChange();
records.forEach((record, index) => {
try {
let feature = turf.cleanCoords(record.layer.toGeoJSON(), { mutate: false });
if (feature.geometry?.type === "Polygon" && typeof turf.booleanValid === "function" && !turf.booleanValid(feature)) {
const parts = turf.unkinkPolygon(feature).features;
if (!parts.length) throw new Error("geometria non riparabile");
removeRecordWithoutPrompt(record);
parts.forEach((part, partIndex) => {
part.properties = { ...(feature.properties || {}), name: `${managedObjectLabel(record, index)} - riparato ${partIndex + 1}`, _lmeManagerId: undefined };
addDrawingFeature(part);
});
repaired++;
} else {
if (JSON.stringify(feature.geometry) !== JSON.stringify(record.layer.toGeoJSON().geometry)) {
replaceManagedLayer(record, feature); repaired++;
} else valid++;
}
} catch { rejected++; }
});
lmeState.managerSelectedObjectIds.clear();
const message = `Controllati ${records.length} oggetti: ${valid} validi, ${repaired} riparati, ${rejected} non riparabili.`;
finishManagedGeometryChange(message);
showGeometryResult("Controllo geometrie completato", message, rejected ? "warning" : "success");
return `${valid} valide, ${repaired} riparate, ${rejected} non riparabili`;
}
function compareSelectedGeometries() {
if (!requireProFeature("advancedGeometry")) return false;
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
const polygons = selectedManagedRecords().filter(record => record.layer instanceof L.Polygon);
if (polygons.length !== 2) { showGeometryResult("Confronta due confini", "Seleziona esattamente due poligoni dalla mappa, dal pannello Layer o dalla tabella oggetti.", "warning"); return false; }
try {
const first = polygons[0].layer.toGeoJSON(); const second = polygons[1].layer.toGeoJSON();
const onlyFirst = turf.difference(turf.featureCollection([first, second]));
const onlySecond = turf.difference(turf.featureCollection([second, first]));
const overlap = turf.intersect(turf.featureCollection([first, second]));
rememberMapChange();
const resultLayers = [];
const addResult = (feature, name, color, role) => {
if (!feature) return;
const overlapResult = role === "overlap";
feature.properties = {
name,
_lmeSource: "Confronto confini",
_lmeColor: color,
_lmeFillColor: color,
_lmeWeight: overlapResult ? 5 : 3,
_lmeOpacity: 1,
_lmeFillOpacity: overlapResult ? 0.72 : 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", "only-first");
addResult(onlySecond, `Solo ${managedObjectLabel(polygons[1], 1)}`, "#2563eb", "only-second");
addResult(overlap, "Sovrapposizione", "#16a34a", "overlap");
const comparisonIds = [...resultLayers].reverse().map(layer => managedLayerId(layer, "drawings"));
const previousOrder = lmeState.managerObjectOrder.drawings || [];
lmeState.managerObjectOrder.drawings = [
...comparisonIds,
...previousOrder.filter(id => !comparisonIds.includes(id))
];
saveLayerManagerState();
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 = `<a href="#" data-comparison-action="compare">Confronta</a><a href="#" data-comparison-action="clear">Pulisci confronto</a>`;
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 selectionHasObjects() {
return lmeState.managerSelectedObjectIds.size > 0;
}
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;
setSelectedDrawing(selectedRecords.length === 1 && current?.categoryKey === "drawings" ? current.layer : null);
renderManagerHighlight(selectedRecords);
}
function selectionModeLabel(mode = lmeState.selectionMode) {
return mode === "multiple" ? "Multipla" : mode === "off" ? "Disattivata" : "Singola";
}
function selectionModeIcon(mode = lmeState.selectionMode) {
return mode === "multiple" ? "☑" : mode === "off" ? "×" : "✓";
}
function updateSelectionModeButton() {
const button = lmeState.selectionModeButton;
if (!button) return;
const label = `Selezione: ${selectionModeLabel()}`;
button.textContent = selectionModeIcon();
button.title = `${label}. Apri le opzioni`;
button.setAttribute("aria-label", button.title);
button.setAttribute("aria-pressed", String(lmeState.selectionMode !== "off"));
button.classList.toggle("is-active", lmeState.selectionMode !== "off");
button.classList.toggle("is-multiple", lmeState.selectionMode === "multiple");
}
function setSelectionMode(mode) {
const normalized = ["single", "multiple", "off"].includes(mode) ? mode : "single";
lmeState.selectionMode = normalized;
if (normalized === "off") {
lmeState.managerSelectedObjectIds.clear();
clearManagerHighlight();
} else if (normalized === "single" && lmeState.managerSelectedObjectIds.size > 1) {
const selectedIds = [...lmeState.managerSelectedObjectIds];
const retained = lmeState.managerSelectedObjectIds.has(lmeState.managerHighlightedObjectId)
? lmeState.managerHighlightedObjectId
: selectedIds.at(-1);
lmeState.managerSelectedObjectIds.clear();
if (retained) lmeState.managerSelectedObjectIds.add(retained);
}
updateSelectionModeButton();
refreshLayerManager();
renderSelectedManagerHighlight();
setInfo(normalized === "single"
? "Selezione singola attiva: tocca un oggetto per sostituire la selezione corrente."
: normalized === "multiple"
? "Selezione multipla attiva: tocca gli oggetti per aggiungerli o rimuoverli."
: "Selezione disattivata e selezione corrente cancellata. Nessun oggetto e' stato rimosso.");
return normalized;
}
function clearManagedSelection(announce = true) {
lmeState.managerSelectedObjectIds.clear();
clearManagerHighlight();
refreshLayerManager();
if (announce) setInfo("Selezione cancellata. Nessun oggetto e' stato rimosso.");
}
function selectManagedRecord(record, event, announce = true) {
if (!record) return false;
if (lmeState.selectionMode === "off") {
if (announce) setInfo("Selezione disattivata. Attivala dal pulsante Selezione nella barra mappa.");
return false;
}
const multiple = lmeState.selectionMode === "multiple"
|| event?.ctrlKey || event?.metaKey || event?.shiftKey;
if (multiple) {
if (lmeState.managerSelectedObjectIds.has(record.id)) lmeState.managerSelectedObjectIds.delete(record.id);
else lmeState.managerSelectedObjectIds.add(record.id);
} else {
lmeState.managerSelectedObjectIds.clear();
lmeState.managerSelectedObjectIds.add(record.id);
}
refreshLayerManager();
refreshMultipleMapSelection(record);
if (lmeState.managerSelectedObjectIds.has(record.id)) {
window.requestAnimationFrame(() => revealManagedRecordInLayerManager(record.id));
}
if (announce) setInfo(`${lmeState.managerSelectedObjectIds.size} oggetti selezionati (${selectionModeLabel().toLocaleLowerCase("it")}).`);
return true;
}
function showSelectionMenu(button) {
if (lmeState.selectionMenu) {
hideSelectionMenu();
return;
}
const menu = L.DomUtil.create("div", "lme-selection-actions", button.parentElement);
menu.style.top = `${button.offsetTop}px`;
menu.innerHTML = `<a href="#" data-selection-mode="single">✓ Selezione singola</a>
<a href="#" data-selection-mode="multiple">☑ Selezione multipla</a>
<a href="#" data-selection-mode="off">× Selezione disattivata</a>
<a href="#" data-selection-clear="true">⌫ Cancella selezione</a>`;
lmeState.selectionMenu = menu;
menu.querySelectorAll("a").forEach(link => {
const active = link.dataset.selectionMode === lmeState.selectionMode;
link.classList.toggle("is-current", active);
link.setAttribute("aria-current", active ? "true" : "false");
L.DomEvent.on(link, "click", L.DomEvent.stop)
.on(link, "mousedown", L.DomEvent.stopPropagation)
.on(link, "touchstart", L.DomEvent.stopPropagation)
.on(link, "click", () => {
const mode = link.dataset.selectionMode;
hideSelectionMenu();
if (mode) setSelectionMode(mode);
else clearManagedSelection();
});
});
}
function hideSelectionMenu() {
lmeState.selectionMenu?.remove();
lmeState.selectionMenu = null;
}
function showGeometryResult(title, message, tone = "success") {
setInfo(`${title}: ${message}`);
const icons = { success: "✓", warning: "!", error: "×" };
showAppDialog(title, `<div class="geometry-result geometry-result-${tone}"><span aria-hidden="true">${icons[tone] || "i"}</span><p>${escapeHtml(message)}</p></div>`);
}
function showLeafletToolbarHelp() {
showAppDialog("Guida strumenti mappa", `<div class="leaflet-help">
<p>Gli strumenti operano direttamente sulla mappa. Per le azioni su oggetti esistenti, seleziona prima le righe nel pannello Layer, nella tabella oggetti oppure tocca un oggetto sulla mappa.</p>
<dl class="leaflet-help-list">
<div><dt>+</dt><dd><strong>Ingrandisci</strong>Aumenta lo zoom mantenendo il centro attuale. Esempio: avvicinati a un vertice per posizionarlo con maggiore precisione.</dd></div>
<div><dt>−</dt><dd><strong>Riduci</strong>Allarga l'area visibile. Esempio: usalo per controllare insieme particelle appartenenti a fogli vicini.</dd></div>
<div><dt>●</dt><dd><strong>Punto di interesse</strong>Tocca la posizione da memorizzare. Esempio: segna un cancello, un cippo o il punto di accesso al fondo.</dd></div>
<div><dt>/</dt><dd><strong>Linea</strong>Tocca i vertici del percorso e termina con doppio tocco o sul primo comando della barra. Durante il disegno viene mostrata la distanza; utile per un fronte strada o una recinzione.</dd></div>
<div><dt>⬠</dt><dd><strong>Poligono</strong>Tocca almeno tre vertici e chiudi la figura. Esempio: delimita un'area di intervento; l'app calcola area e perimetro.</dd></div>
<div><dt>□</dt><dd><strong>Rettangolo</strong>Trascina tra due angoli opposti. Esempio: crea rapidamente l'ingombro rettangolare di un fabbricato.</dd></div>
<div><dt>○</dt><dd><strong>Cerchio</strong>Tocca il centro e trascina fino al raggio desiderato. Esempio: rappresenta una fascia di rispetto attorno a un pozzo.</dd></div>
<div><dt>✎</dt><dd><strong>Modifica disegni</strong>Premi la matita e scegli Solo selezionato oppure Tutti. Sposta i vertici e conferma con Salva; gli oggetti bloccati devono prima essere sbloccati.</dd></div>
<div><dt>▱</dt><dd><strong>Elimina disegni</strong>Premi il cestino e scegli Solo selezionato oppure Tutti. L'oggetto rimosso può essere recuperato con Annulla o dal cestino negli strumenti avanzati.</dd></div>
<div><dt>✓</dt><dd><strong>Controlla e ripara</strong>Verifica 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.</dd></div>
<div><dt>◉</dt><dd><strong>Crea foro</strong>Seleziona 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.</dd></div>
<div><dt>✓</dt><dd><strong>Modalita di selezione</strong>Scegli Singola, Multipla o Disattivata. Mappa, pannello Layer e tabella oggetti condividono la stessa selezione. Disattivata e Cancella selezione deselezionano tutto senza eliminare alcun oggetto.</dd></div>
<div><dt>⇄</dt><dd><strong>Confronta due confini</strong>Apri il comando e scegli Confronta dopo aver selezionato esattamente due poligoni. Il gruppo Confronto confini viene collocato sopra gli altri layer: rosso indica solo il primo, blu solo il secondo e verde la sovrapposizione. Usa Pulisci confronto per rimuovere soltanto questi risultati.</dd></div>
<div><dt>PF</dt><dd><strong>Scarica Punti Fiduciali</strong>Seleziona 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.</dd></div>
<div><dt>?</dt><dd><strong>Guida</strong>Riapre 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.</dd></div>
</dl>
</div>`);
}
function addGeometryToolsControl() {
if (lmeState.geometryToolsControl || !lmeState.map) return;
const GeometryTools = L.Control.extend({
options: { position: "topright" },
onAdd() {
const container = L.DomUtil.create("div", "leaflet-bar lme-geometry-toolbar");
const actions = [
{ icon: "✓", title: "Controlla e ripara geometrie", run: validateAndRepairSelected },
{ icon: "◉", title: "Crea foro da due poligoni", run: createHoleFromSelected },
{ icon: selectionModeIcon(), title: "Modalita e comandi di selezione", selection: 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 }
];
actions.forEach(action => {
const button = L.DomUtil.create("a", "", container);
button.href = "#";
button.title = action.title;
button.setAttribute("role", "button");
button.setAttribute("aria-label", action.title);
button.textContent = action.icon;
if (action.fiducials) {
button.classList.add("lme-fiducial-download");
lmeState.fiducialDownloadButton = button;
}
if (action.comparison) button.classList.add("lme-comparison-button");
if (action.selection) {
button.classList.add("lme-selection-mode-button");
lmeState.selectionModeButton = button;
updateSelectionModeButton();
}
L.DomEvent.on(button, "click", event => {
L.DomEvent.stop(event);
if (action.comparison) showComparisonMenu(button);
else if (action.selection) showSelectionMenu(button);
else action.run();
});
});
L.DomEvent.disableClickPropagation(container);
return container;
}
});
lmeState.geometryToolsControl = new GeometryTools().addTo(lmeState.map);
updateFiducialDownloadButton();
}
function downloadFiducialsForMapContext() {
if (!requireProFeature("fiducialPoints")) return false;
const context = currentFiducialMapContext();
if (!context) {
setInfo("Seleziona una particella o un foglio catastale. Se il GPS è attivo puoi usare anche la particella corrente.");
return false;
}
if (!lmeState.dotNetReference) {
setInfo("Servizio PF non ancora pronto. Riprova tra poco.");
return false;
}
void lmeState.dotNetReference.invokeMethodAsync(
"DownloadFiducialsForMapSelection",
context.municipalityCode,
context.sheet,
context.label);
return true;
}
function currentFiducialMapContext() {
const selected = selectedManagedRecords();
if (!selected.length && lmeState.managerHighlightedObjectId) {
const highlighted = lmeState.managerRecords?.get?.(lmeState.managerHighlightedObjectId);
if (highlighted) selected.push(highlighted);
}
const preferred = selected.filter(record => ["selectedParcels", "gpsParcels", "importedSheets", "imported"].includes(record.categoryKey));
for (const record of preferred) {
const context = fiducialContextFromLayer(record.layer);
if (context) return context;
}
const gpsLayers = managedRecordsForCategory(layerManagerCategories().find(category => category.key === "gpsParcels"));
for (const record of gpsLayers) {
const context = fiducialContextFromLayer(record.layer);
if (context) return context;
}
return null;
}
function fiducialContextFromLayer(layer) {
const properties = layer?.feature?.properties || {};
const municipalityCode = String(properties._lmeMunicipalityCode || "").trim().toUpperCase();
const sheet = normalizeFiducialSheet(properties._lmeSheet || properties.foglio || properties.FOGLIO || properties.sheet || properties.SHEET);
if (/^[A-Z][0-9A-Z]{3}$/.test(municipalityCode) && sheet) {
return { municipalityCode, sheet, label: `Comune ${municipalityCode}, foglio ${sheet}` };
}
const references = [
properties.NATIONALCADASTRALREFERENCE,
properties.NATIONALCADASTRALZONINGREFERENCE,
properties.INSPIREID_LOCALID,
properties._lmeParcelReference,
properties.LABEL
].filter(Boolean).map(value => String(value).toUpperCase());
for (const reference of references) {
const match = reference.match(/(?:^|\.)([A-Z][0-9A-Z]{3})[_-](\d{4})(?:\d{2})?(?:[._-]|$)/);
if (match) {
const parsedSheet = normalizeFiducialSheet(match[2]);
return { municipalityCode: match[1], sheet: parsedSheet, label: `Comune ${match[1]}, foglio ${parsedSheet}` };
}
}
return null;
}
function normalizeFiducialSheet(value) {
const digits = String(value ?? "").match(/\d+/)?.[0] || "";
if (!digits) return "";
return String(Number.parseInt(digits, 10));
}
function updateFiducialDownloadButton() {
const button = lmeState.fiducialDownloadButton;
if (!button) return;
const enabled = Boolean(lmeState.entitlements?.fiducialPoints && currentFiducialMapContext());
button.classList.toggle("is-disabled", !enabled);
button.setAttribute("aria-disabled", String(!enabled));
button.title = enabled
? "Scarica PF del foglio o della particella"
: "Seleziona una particella o un foglio per scaricare i PF";
}
async function numericEditManagedRecord(record) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (record.layer?.feature?.properties?._lmeLocked) { setInfo("Sblocca prima l'oggetto."); return; }
if (record.layer instanceof L.Circle) {
const center = record.layer.getLatLng();
const result = await showFormDialog("Modifica cerchio", [{ name: "coordinates", label: "Latitudine, longitudine, raggio in metri", type: "textarea", rows: 3, value: `${center.lat.toFixed(7)}, ${center.lng.toFixed(7)}, ${record.layer.getRadius().toFixed(2)}` }], "Applica");
if (!result) return;
const value = result.coordinates;
const parts = value.split(/[;, ]+/).map(Number).filter(Number.isFinite);
if (parts.length < 3) { setInfo("Inserisci latitudine, longitudine e raggio validi."); return; }
rememberMapChange(); record.layer.setLatLng([parts[0], parts[1]]); record.layer.setRadius(parts[2]); annotateDrawingLayer(record.layer, "circle");
} else if (typeof record.layer?.getLatLng === "function") {
const point = record.layer.getLatLng();
const result = await showFormDialog("Modifica punto", [{ name: "coordinates", label: "Latitudine, longitudine", type: "textarea", rows: 3, value: `${point.lat.toFixed(7)}, ${point.lng.toFixed(7)}` }], "Applica");
if (!result) return;
const value = result.coordinates;
const parts = value.split(/[;, ]+/).map(Number).filter(Number.isFinite);
if (parts.length < 2) { setInfo("Inserisci coordinate valide."); return; }
rememberMapChange(); record.layer.setLatLng([parts[0], parts[1]]);
} else {
const lines = lineLatLngGroups(record.layer.getLatLngs());
const current = lines[0]?.map(point => `${point.lat.toFixed(7)},${point.lng.toFixed(7)}`).join("; ") || "";
const result = await showFormDialog("Modifica vertici", [{ name: "coordinates", label: "Coppie latitudine,longitudine separate da punto e virgola", type: "textarea", rows: 8, value: current }], "Applica");
if (!result) return;
const value = result.coordinates;
const points = value.split(";").map(pair => pair.split(",").map(Number)).filter(pair => pair.length === 2 && pair.every(Number.isFinite));
if (points.length < 2) { setInfo("Servono almeno due vertici validi."); return; }
rememberMapChange(); record.layer.setLatLngs(points.map(point => L.latLng(point[0], point[1])));
}
finishManagedGeometryChange("Coordinate aggiornate.");
}
function replaceManagedLayer(record, feature) {
const parent = record.parent?.hasLayer?.(record.layer) ? record.parent : lmeState.drawings;
parent.removeLayer?.(record.layer);
clearManagedObjectState(record.id);
feature.properties = { ...(record.layer.feature?.properties || {}), ...(feature.properties || {}), _lmeManagerId: undefined };
addDrawingFeature(feature);
}
function removeRecordWithoutPrompt(record) {
record.parent?.removeLayer?.(record.layer); clearManagedObjectState(record.id);
}
function finishManagedGeometryChange(message) {
refreshImportedMeasurements(); refreshDrawingMeasurements(); refreshPermanentLabels(); updateGpsInsideState(); refreshLayerManager(); finishMapChange(); setInfo(message);
}
function rememberMapChange() {
if (lmeState.historyBusy || !lmeState.map) return;
const snapshot = createProjectSnapshot(true);
if (lmeState.undoStack.at(-1) !== snapshot) lmeState.undoStack.push(snapshot);
if (lmeState.undoStack.length > 30) lmeState.undoStack.shift();
lmeState.redoStack = [];
}
function finishMapChange() {
if (lmeState.historyBusy || !lmeState.map) return;
const action = arguments[0] || "Modifica mappa";
lmeState.isDirty = true;
lmeState.auditLog.push({ at: new Date().toISOString(), action });
if (lmeState.auditLog.length > 250) lmeState.auditLog.shift();
if (lmeState.autoSaveEnabled) {
try { localStorage.setItem("lmeAutosaveV3", createProjectSnapshot(true)); } catch { }
}
window.clearTimeout(lmeState.changeNotifyTimer);
lmeState.changeNotifyTimer = window.setTimeout(() => {
void lmeState.dotNetReference?.invokeMethodAsync("OnMapChanged", action);
}, 450);
}
function getHistoryState() {
return { canUndo: lmeState.undoStack.length > 0, canRedo: lmeState.redoStack.length > 0, dirty: lmeState.isDirty };
}
function markProjectSaved() {
lmeState.isDirty = false;
void lmeState.dotNetReference?.invokeMethodAsync("OnProjectSaved");
}
function confirmDiscardChanges(actionLabel) {
return !lmeState.isDirty || window.confirm(`Ci sono modifiche non salvate. Continuare e ${actionLabel || "abbandonarle"}?`);
}
function setProjectReadOnly(enabled) {
lmeState.readOnly = Boolean(enabled);
document.querySelector(".map-app")?.classList.toggle("project-readonly", lmeState.readOnly);
if (lmeState.readOnly) setDrawToolsVisible(false);
refreshLayerManager();
setInfo(lmeState.readOnly ? "Progetto aperto in sola lettura. Duplicalo per modificarlo." : "Progetto modificabile.");
}
function undoMapChange() {
if (!lmeState.undoStack.length) { setInfo("Nessuna operazione da annullare."); return; }
lmeState.redoStack.push(createProjectSnapshot(true));
restoreHistorySnapshot(lmeState.undoStack.pop(), "Operazione annullata.");
lmeState.isDirty = true;
}
function redoMapChange() {
if (!lmeState.redoStack.length) { setInfo("Nessuna operazione da ripristinare."); return; }
lmeState.undoStack.push(createProjectSnapshot(true));
restoreHistorySnapshot(lmeState.redoStack.pop(), "Operazione ripristinata.");
lmeState.isDirty = true;
}
function restoreLastTrashItem() {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
const entry = lmeState.trash.pop();
if (!entry) { setInfo("Il cestino e' vuoto."); return false; }
const drawingsToRestore = entry.items.filter(item =>
!["imported", "importedSheets", "fiducials"].includes(item.categoryKey)).length;
if (!canAddDrawingObjects(drawingsToRestore)) {
lmeState.trash.push(entry);
return false;
}
rememberMapChange();
entry.items.forEach(item => {
if (item.categoryKey === "imported") addImportedFeatures({ type: "FeatureCollection", features: [item.feature] });
else if (item.categoryKey === "importedSheets") addImportedSheetFeatures({ type: "FeatureCollection", features: [item.feature] });
else if (item.categoryKey === "fiducials") {
L.geoJSON(item.feature, {
pointToLayer: (feature, latlng) => createFiducialMarker(feature, latlng),
onEachFeature: (feature, layer) => bindFiducialPopup(feature, layer)
}).eachLayer(layer => lmeState.fiducials.addLayer(layer));
}
else addDrawingFeature(item.feature);
});
refreshLayerManager();
finishManagedGeometryChange(`Ripristinati ${entry.items.length} oggetti dal cestino.`);
return true;
}
function emptyTrash() {
if (!lmeState.trash.length) { setInfo("Il cestino e' gia vuoto."); return 0; }
if (!window.confirm(`Eliminare definitivamente ${lmeState.trash.reduce((sum, entry) => sum + entry.items.length, 0)} oggetti dal cestino?`)) return lmeState.trash.length;
lmeState.trash = [];
setInfo("Cestino svuotato.");
return 0;
}
function restoreHistorySnapshot(snapshot, message) {
lmeState.historyBusy = true;
try { loadProjectSnapshot(snapshot, "cronologia"); setInfo(message); } finally { lmeState.historyBusy = false; }
}
function restoreAutosave() {
const snapshot = localStorage.getItem("lmeAutosaveV3");
if (!snapshot) { setInfo("Nessun recupero automatico disponibile."); return false; }
rememberMapChange(); loadProjectSnapshot(snapshot, "recupero automatico"); return true;
}
function setAdvancedOptions(options) {
const value = options || {};
lmeState.snapEnabled = value.snapEnabled !== false;
lmeState.snapDistancePixels = Math.max(4, Math.min(40, Number(value.snapDistancePixels) || 14));
lmeState.autoSaveEnabled = value.autoSaveEnabled !== false;
lmeState.autoFitSelectedObject = Boolean(value.autoFitSelectedObject);
lmeState.gpsProximityEnabled = value.gpsProximityEnabled !== false;
lmeState.gpsBoundaryAlertEnabled = Boolean(value.gpsBoundaryAlertEnabled);
}
function compareProjectSnapshots(currentRaw, previousRaw) {
const current = typeof currentRaw === "string" ? JSON.parse(currentRaw) : currentRaw;
const previous = typeof previousRaw === "string" ? JSON.parse(previousRaw) : previousRaw;
const collect = snapshot => ["drawings", "imported", "importedSheets", "selectedParcels"]
.flatMap(key => snapshot?.[key]?.features || []);
const keyFor = (feature, index) => feature?.properties?._lmeManagerId
|| `${feature?.properties?.name || feature?.properties?.Name || "object"}:${index}`;
const currentMap = new Map(collect(current).map((feature, index) => [keyFor(feature, index), JSON.stringify(feature)]));
const previousMap = new Map(collect(previous).map((feature, index) => [keyFor(feature, index), JSON.stringify(feature)]));
let added = 0; let removed = 0; let changed = 0;
currentMap.forEach((value, key) => { if (!previousMap.has(key)) added++; else if (previousMap.get(key) !== value) changed++; });
previousMap.forEach((_value, key) => { if (!currentMap.has(key)) removed++; });
const message = `Confronto revisione: ${added} aggiunti, ${removed} rimossi, ${changed} modificati.`;
setInfo(message);
return message;
}
function renderLayerManagerVisibilityOption(label, checked, onChange) {
const option = document.createElement("label");
option.className = "layer-manager-suboption";
option.innerHTML = `<input type="checkbox" ${checked ? "checked" : ""}><span>${escapeHtml(label)}</span>`;
const checkbox = option.querySelector("input");
checkbox.addEventListener("click", event => event.stopPropagation());
checkbox.addEventListener("change", () => onChange(checkbox.checked));
return option;
}
function geometryVisibilityLabel(layer) {
if (layer instanceof L.Polygon || layer instanceof L.Circle) {
return "Area/geometria sulla mappa";
}
if (layer instanceof L.Polyline) {
return "Linea sulla mappa";
}
return "Punto sulla mappa";
}
function supportsManagedMeasurements(record) {
return ["imported", "drawings"].includes(record.categoryKey)
&& (record.layer instanceof L.Circle
|| record.layer instanceof L.Polygon
|| record.layer instanceof L.Polyline);
}
function managedMeasurementsGloballyVisible(categoryKey) {
return categoryKey === "imported"
? lmeState.showImportedMeasurements
: lmeState.showDrawingMeasurements;
}
function managedMeasurementKinds(record) {
if (record.layer instanceof L.Polygon) {
return [
{ key: "summary", label: "Superficie e perimetro sulla mappa" },
{ key: "sides", label: "Misure dei lati sulla mappa" }
];
}
if (record.layer instanceof L.Circle) {
return [{ key: "summary", label: "Superficie, raggio e circonferenza sulla mappa" }];
}
return [
{ key: "summary", label: "Lunghezza totale sulla mappa" },
{ key: "sides", label: "Misure dei tratti sulla mappa" }
];
}
function managedMeasurementHiddenSet(kind) {
return kind === "sides"
? lmeState.managerHiddenMeasurementSideIds
: lmeState.managerHiddenMeasurementSummaryIds;
}
function setManagedMeasurementKindVisible(record, kind, visible) {
const hiddenSet = managedMeasurementHiddenSet(kind);
if (visible) {
if (!managedMeasurementsGloballyVisible(record.categoryKey)) {
lmeState.managerRecords.forEach(candidate => {
if (candidate.categoryKey === record.categoryKey
&& supportsManagedMeasurements(candidate)) {
lmeState.managerHiddenMeasurementSummaryIds.add(candidate.id);
lmeState.managerHiddenMeasurementSideIds.add(candidate.id);
}
});
if (record.categoryKey === "imported") {
lmeState.showImportedMeasurements = true;
} else {
lmeState.showDrawingMeasurements = true;
}
void lmeState.dotNetReference?.invokeMethodAsync(
"SetMeasurementsFromLayerManager",
record.categoryKey,
true);
}
hiddenSet.delete(record.id);
} else {
hiddenSet.add(record.id);
}
refreshImportedMeasurements();
refreshDrawingMeasurements();
refreshLayerManager();
}
function captureLayerManagerExpansion(container) {
container.querySelectorAll("details[data-expansion-key]").forEach(row => {
if (row.open) {
lmeState.managerExpandedRows.add(row.dataset.expansionKey);
} else {
lmeState.managerExpandedRows.delete(row.dataset.expansionKey);
}
});
}
function bindLayerManagerExpansion(row, key) {
row.dataset.expansionKey = key;
row.open = lmeState.managerExpandedRows.has(key);
row.addEventListener("toggle", () => {
if (row.open) {
lmeState.managerExpandedRows.add(key);
} else {
lmeState.managerExpandedRows.delete(key);
}
const button = row.querySelector(":scope > summary > .layer-expand-button");
if (button) {
button.textContent = row.open ? "▾" : "▸";
button.setAttribute("aria-expanded", String(row.open));
}
});
}
function layerExpandButton(expanded, label) {
return `<button type="button" class="layer-expand-button" aria-label="${expanded ? "Comprimi" : "Espandi"} ${escapeHtml(label)}" aria-expanded="${expanded}">${expanded ? "▾" : "▸"}</button>`;
}
function bindLayerManagerToggleButton(row) {
const summary = row.querySelector(":scope > summary");
const button = summary?.querySelector(".layer-expand-button");
summary?.addEventListener("click", event => event.preventDefault());
button?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
row.open = !row.open;
});
}
function setManagedCategoryVisible(category, visible) {
if (category.key === "imported") {
lmeState.importedVisible = visible;
} else if (category.key === "importedSheets") {
lmeState.importedSheetsVisible = visible;
} else if (category.key === "selectedParcels") {
lmeState.selectedParcelsVisible = visible;
} else if (category.key === "fiducials") {
lmeState.fiducialsVisible = visible;
} else if (category.key === "gpsPosition") {
lmeState.gpsPositionVisible = visible;
} else if (category.key === "compass") {
lmeState.compassVisible = visible;
void lmeState.dotNetReference?.invokeMethodAsync("SetCompassFromLayerManager", visible);
if (!visible) {
lmeState.map.setHeading?.(null);
lmeState.map.setBearing?.(0);
}
}
if (visible) {
lmeState.managerHiddenCategories.delete(category.key);
category.roots.forEach(layer => {
const objectId = managedLayerId(layer, category.key);
if (layer
&& !lmeState.managerHiddenObjectIds.has(objectId)
&& !lmeState.map.hasLayer(layer)) {
layer.addTo(lmeState.map);
}
});
} else {
lmeState.managerHiddenCategories.add(category.key);
category.roots.forEach(layer => {
if (layer && lmeState.map.hasLayer(layer)) {
lmeState.map.removeLayer(layer);
}
});
if (lmeState.managerHighlightedCategoryKey === category.key) {
clearManagerHighlight();
}
}
saveLayerManagerState();
refreshImportedMeasurements();
refreshDrawingMeasurements();
refreshLayerManager();
}
function setManagedObjectVisible(record, visible, refresh = true) {
if (visible) {
const hidden = lmeState.managerHiddenObjects.get(record.id);
if (hidden) {
if (hidden.parent !== lmeState.map || managedCategoryEnabled(record.categoryKey)) {
hidden.parent.addLayer(hidden.layer);
}
lmeState.managerHiddenObjects.delete(record.id);
}
lmeState.managerHiddenObjectIds.delete(record.id);
} else if (!lmeState.managerHiddenObjects.has(record.id)) {
lmeState.managerHiddenObjects.set(record.id, record);
lmeState.managerHiddenObjectIds.add(record.id);
record.parent.removeLayer(record.layer);
if (lmeState.managerHighlightedObjectId === record.id) {
clearManagerHighlight();
}
}
saveLayerManagerState();
refreshImportedMeasurements();
refreshDrawingMeasurements();
refreshPermanentLabels();
if (refresh) {
refreshLayerManager();
}
}
function managedCategoryEnabled(categoryKey) {
if (lmeState.managerHiddenCategories.has(categoryKey)) {
return false;
}
if (categoryKey === "gpsPosition") {
return lmeState.gpsPositionVisible;
}
if (categoryKey === "imported") {
return lmeState.importedVisible;
}
if (categoryKey === "importedSheets") {
return lmeState.importedSheetsVisible;
}
if (categoryKey === "selectedParcels") {
return lmeState.selectedParcelsVisible;
}
if (categoryKey === "fiducials") {
return lmeState.fiducialsVisible;
}
return true;
}
function highlightManagedObject(record) {
if (!selectManagedRecord(record)) return;
if (lmeState.autoFitSelectedObject) {
fitLayer(record.layer);
}
}
function revealManagedRecordInLayerManager(recordId) {
const row = document.querySelector(`.layer-manager-object[data-object-id="${CSS.escape(recordId)}"]`);
if (!row) return;
const block = row.closest(".layer-manager-block");
const group = row.closest(".layer-manager-group");
if (group) group.classList.add("layer-manager-selection-path");
if (block) block.classList.add("layer-manager-selection-path");
row.classList.add("layer-manager-object-selected");
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
function drawingEditToolbar() {
return lmeState.drawControl?._toolbars?.[L.EditToolbar.TYPE] || null;
}
function drawingEditActive() {
return Boolean(drawingEditToolbar()?.enabled?.());
}
function updateDrawingSelectionActions() {
const hasSelection = Boolean(lmeState.drawingSelection?.getLayers?.().length);
const hasDrawings = Boolean(lmeState.drawings?.getLayers?.().length);
lmeState.drawingDeleteButton?.classList.toggle("leaflet-disabled", !hasDrawings);
if (lmeState.drawingDeleteButton) {
lmeState.drawingDeleteButton.setAttribute("aria-disabled", String(!hasDrawings));
}
lmeState.drawingScopeMenu?.querySelector("[data-drawing-scope='selected']")
?.setAttribute("aria-disabled", String(!hasSelection));
}
function setSelectedDrawing(layer) {
if (!lmeState.drawingSelection || drawingEditActive()) {
return;
}
lmeState.drawingSelection.clearLayers();
if (layer) {
lmeState.drawingSelection.addLayer(layer);
}
updateDrawingSelectionActions();
}
function selectDrawingLayer(layer) {
if (drawingEditActive() || lmeState.selectionMode === "off") {
return;
}
const record = {
id: managedLayerId(layer, "drawings"),
categoryKey: "drawings",
layer,
parent: lmeState.drawings,
source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente")
};
lmeState.managerSelectedObjectIds.clear();
lmeState.managerSelectedObjectIds.add(record.id);
refreshLayerManager();
refreshMultipleMapSelection(record);
setInfo(`Selezione: ${managedObjectLabel(record, 0)}. Usa la matita per modificare o il cestino per eliminare.`);
}
function bindManagedParentHighlight(row, key, records) {
const summary = row.querySelector(":scope > summary");
row.dataset.highlightKey = key;
row.classList.toggle("layer-manager-parent-selected", lmeState.managerHighlightedParentKey === key);
if (lmeState.managerHighlightedParentKey === key) {
lmeState.managerHighlightedParentRecords = records;
}
row.querySelector(":scope > summary > .layer-manager-title").addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
highlightManagedParent(row, key, records);
});
summary.addEventListener("pointerenter", () => {
lmeState.managerHoveredParentKey = key;
lmeState.managerHoveredParentRecords = records;
row.classList.add("layer-manager-parent-highlighted");
renderManagerHighlight(records);
});
summary.addEventListener("pointerleave", () => {
if (lmeState.managerHoveredParentKey !== key) {
return;
}
lmeState.managerHoveredParentKey = null;
lmeState.managerHoveredParentRecords = null;
row.classList.remove("layer-manager-parent-highlighted");
renderSelectedManagerHighlight();
});
}
function highlightManagedParent(row, key, records) {
if (lmeState.selectionMode === "off") {
setInfo("Selezione disattivata. Scegli Singola o Multipla dalla barra mappa.");
return;
}
if (lmeState.managerHighlightedParentKey === key) {
clearManagerHighlight();
return;
}
clearManagerHighlight();
lmeState.managerHighlightedParentKey = key;
lmeState.managerHighlightedParentRecords = records;
lmeState.managerHighlightedCategoryKey = records[0]?.categoryKey || null;
setSelectedDrawing(null);
row.classList.add("layer-manager-parent-selected");
renderManagerHighlight(records);
}
function renderSelectedManagerHighlight() {
if (selectionHasObjects()) {
const selected = [...lmeState.managerSelectedObjectIds]
.map(id => lmeState.managerRecords.get(id))
.filter(Boolean);
renderManagerHighlight(selected);
return;
}
if (lmeState.managerHighlightedParentRecords?.length) {
renderManagerHighlight(lmeState.managerHighlightedParentRecords);
return;
}
const selected = lmeState.managerRecords.get(lmeState.managerHighlightedObjectId);
renderManagerHighlight(selected ? [selected] : []);
}
function renderManagerHighlight(records) {
restoreDirectManagerHighlights();
lmeState.managerHighlight?.clearLayers();
records.forEach(record => applyDirectManagerHighlight(record));
const features = records.flatMap(record => {
const geojson = record.layer?.toGeoJSON?.();
if (!geojson) {
return [];
}
const selectionColor = managedSelectionColor(record.layer);
const items = geojson.type === "FeatureCollection" ? geojson.features : [geojson];
return items.map(feature => ({
...feature,
properties: { ...(feature.properties || {}), _lmeSelectionColorResolved: selectionColor }
}));
});
if (!features.length) {
return;
}
L.geoJSON({ type: "FeatureCollection", features }, {
pane: "managerHighlightPane",
interactive: false,
style: feature => ({
color: managedSelectionColor(null, feature?.properties?._lmeSelectionColorResolved),
weight: 8,
opacity: 1,
fillColor: managedSelectionColor(null, feature?.properties?._lmeSelectionColorResolved),
fillOpacity: 0.22
}),
pointToLayer: (feature, latlng) => L.circleMarker(latlng, {
pane: "managerHighlightPane",
radius: 12,
color: managedSelectionColor(null, feature?.properties?._lmeSelectionColorResolved),
weight: 5,
fillColor: managedSelectionColor(null, feature?.properties?._lmeSelectionColorResolved),
fillOpacity: 0.9,
interactive: false
})
}).addTo(lmeState.managerHighlight);
}
function managedSelectionColor(layer, explicitColor) {
return normalizedColor(explicitColor || layer?.feature?.properties?._lmeSelectionColor, "#d500f9");
}
function applyDirectManagerHighlight(record) {
const layer = record?.layer;
if (!layer || typeof layer.setStyle !== "function" || lmeState.managerDirectHighlightStyles.has(layer)) return;
const options = layer.options || {};
const selectionColor = managedSelectionColor(layer);
lmeState.managerDirectHighlightStyles.set(layer, {
color: options.color,
weight: options.weight,
opacity: options.opacity,
fillColor: options.fillColor,
fillOpacity: options.fillOpacity,
dashArray: options.dashArray ?? null
});
layer.setStyle({
color: selectionColor,
weight: Math.max(7, (Number(options.weight) || 2) + 4),
opacity: 1,
fillColor: selectionColor,
fillOpacity: 0.34,
dashArray: null
});
}
function restoreDirectManagerHighlights() {
lmeState.managerDirectHighlightStyles.forEach((style, layer) => {
if (typeof layer?.setStyle === "function") layer.setStyle(style);
});
lmeState.managerDirectHighlightStyles.clear();
}
function clearManagerHighlight() {
restoreDirectManagerHighlights();
lmeState.managerHighlight?.clearLayers();
lmeState.managerHighlightedObjectId = null;
lmeState.managerHighlightedCategoryKey = null;
lmeState.managerHighlightedParentKey = null;
lmeState.managerHighlightedParentRecords = null;
lmeState.managerHoveredParentKey = null;
lmeState.managerHoveredParentRecords = null;
setSelectedDrawing(null);
document.querySelectorAll(".layer-manager-object-selected").forEach(row => {
row.classList.remove("layer-manager-object-selected");
});
document.querySelectorAll(".layer-manager-parent-highlighted").forEach(row => {
row.classList.remove("layer-manager-parent-highlighted");
});
document.querySelectorAll(".layer-manager-parent-selected").forEach(row => {
row.classList.remove("layer-manager-parent-selected");
});
}
function layerManagerMoveButtons(label) {
return `<span class="layer-move-buttons"><button type="button" class="layer-move-up" aria-label="Sposta ${label} su" title="Sposta su">↑</button><button type="button" class="layer-move-down" aria-label="Sposta ${label} giù" title="Sposta giù">↓</button></span>`;
}
function bindLayerManagerDrag(container) {
container.querySelectorAll(".layer-drag-handle").forEach(handle => {
handle.addEventListener("pointerdown", event => {
event.preventDefault();
event.stopPropagation();
const row = handle.closest("[data-drag-type]");
const list = row.parentElement;
row.classList.add("layer-manager-dragging");
handle.setPointerCapture?.(event.pointerId);
const move = moveEvent => {
moveEvent.preventDefault();
const target = document.elementFromPoint(moveEvent.clientX, moveEvent.clientY)?.closest(`[data-drag-type="${row.dataset.dragType}"]`);
if (!target || target === row || target.parentElement !== list) {
return;
}
const targetRect = target.getBoundingClientRect();
list.insertBefore(row, moveEvent.clientY < targetRect.top + targetRect.height / 2 ? target : target.nextSibling);
};
const end = () => {
row.classList.remove("layer-manager-dragging");
handle.releasePointerCapture?.(event.pointerId);
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", end);
document.removeEventListener("pointercancel", end);
applyLayerManagerDomOrder(container);
};
document.addEventListener("pointermove", move, { passive: false });
document.addEventListener("pointerup", end);
document.addEventListener("pointercancel", end);
});
});
}
function bindLayerManagerMoveButtons(container) {
container.querySelectorAll(".layer-move-up, .layer-move-down").forEach(button => {
button.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
const row = button.closest("[data-drag-type]");
const sibling = button.classList.contains("layer-move-up")
? row.previousElementSibling
: row.nextElementSibling;
if (!sibling || sibling.dataset.dragType !== row.dataset.dragType) {
return;
}
if (button.classList.contains("layer-move-up")) {
row.parentElement.insertBefore(row, sibling);
} else {
row.parentElement.insertBefore(sibling, row);
}
applyLayerManagerDomOrder(container);
});
});
}
function applyLayerManagerDomOrder(container) {
const categoryKeys = [...container.children].map(row => row.dataset.categoryKey).filter(Boolean);
setLayerOrder(categoryKeys);
container.querySelectorAll(":scope > .layer-manager-group").forEach(group => {
const objectIds = [...group.querySelectorAll(".layer-manager-object")].map(row => row.dataset.objectId).filter(Boolean);
lmeState.managerObjectOrder[group.dataset.categoryKey] = objectIds;
});
applyManagedVisualOrder(container);
saveLayerManagerState();
}
function applyManagedVisualOrder(container) {
container.querySelectorAll(":scope > .layer-manager-group").forEach(group => {
const objectIds = [...group.querySelectorAll(".layer-manager-object")].map(row => row.dataset.objectId).filter(Boolean);
[...objectIds].reverse().forEach(id => lmeState.managerRecords.get(id)?.layer?.bringToFront?.());
objectIds.forEach((id, index) => lmeState.managerRecords.get(id)?.layer?.setZIndexOffset?.(1000 - index));
});
}
async function setImportedSheetLayerVisible(enabled) {
migrateMisclassifiedImportedSheets();
lmeState.importedSheetsVisible = Boolean(enabled);
let sheetResult = null;
if (lmeState.importedSheetsVisible) {
setInfo("Carico i fogli catastali delle particelle...");
sheetResult = await ensureImportedSheetOutlines();
}
applyImportedSheetVisibility();
const count = lmeState.importedSheets?.toGeoJSON()?.features?.length || 0;
setInfo(lmeState.importedSheetsVisible
? (count
? `${sheetResult?.added ? "Fogli catastali ufficiali caricati" : "Layer dei fogli catastali visibile"} (${count} cornici).`
: (sheetResult?.failed
? "Il servizio ufficiale dei fogli catastali non e' al momento disponibile. Riprova mostrando nuovamente il layer."
: "Nessuna particella con cui individuare il foglio catastale."))
: "Layer del foglio importato nascosto; le particelle restano visibili.");
}
function removeLegacyGeneratedSheetOutlines() {
const category = layerManagerCategories().find(item => item.key === "importedSheets");
if (!category) return 0;
const generated = managedRecordsForCategory(category).filter(record =>
Boolean(record.layer?.feature?.properties?._lmeGeneratedSheetOutline));
generated.forEach(record => {
record.parent?.removeLayer?.(record.layer);
clearManagedObjectState(record.id);
});
return generated.length;
}
function cadastralSheetIdentity(feature, index) {
const properties = feature?.properties || {};
const explicit = properties.foglio ?? properties.FOGLIO ?? properties.sheet ?? properties.SHEET;
const administrativeUnit = String(properties.ADMINISTRATIVEUNIT || "").trim().toUpperCase();
if (String(explicit ?? "").trim()) {
return `${administrativeUnit}|${String(explicit).trim().replace(/^0+/, "") || "0"}`;
}
const context = [
properties.NATIONALCADASTRALREFERENCE,
properties.INSPIREID_LOCALID,
properties._lmeParcelReference,
properties._lmeSource,
properties._lmeFolder,
properties.name,
properties.Name
].filter(Boolean).join(" ").toUpperCase();
const namedSheet = context.match(/\bFOGLIO\s*[_ .-]*0*(\d+[A-Z]?)\b/i);
const codedSheet = context.match(/\b([A-Z]\d{3})[._-]([0-9]{4}[A-Z0-9]{2})(?=[._/-]|\b)/i);
if (namedSheet) return `${administrativeUnit}|${namedSheet[1]}`;
if (codedSheet) return `${codedSheet[1]}|${codedSheet[2]}`;
return `geometria-${index}`;
}
function sheetRepresentativePoint(feature) {
try {
return turf.pointOnFeature(feature).geometry.coordinates;
} catch {
const bounds = geoJsonBounds(feature?.geometry);
return bounds ? boundsCenter(bounds) : null;
}
}
function cadastralSheetFeatureKey(feature) {
const properties = feature?.properties || {};
return String(properties.NATIONALCADASTRALZONINGREFERENCE
|| properties.INSPIREID_LOCALID
|| properties.LABEL
|| JSON.stringify(feature?.geometry || {})).trim().toUpperCase();
}
async function ensureImportedSheetOutlines() {
if (!lmeState.imported || !lmeState.importedSheets) {
return { added: 0, failed: false };
}
removeLegacyGeneratedSheetOutlines();
const existingAreaSheets = layerManagerCategories()
.flatMap(category => managedRecordsForCategory(category))
.map(record => ({ categoryKey: record.categoryKey, feature: record.layer?.toGeoJSON?.() }))
.filter(item => ["Polygon", "MultiPolygon"].includes(item.feature?.geometry?.type)
&& !item.feature?.properties?._lmeGeneratedSheetOutline
&& (item.categoryKey === "importedSheets"
|| item.feature?.properties?._lmeOfficialSheet
|| item.feature?.properties?.NATIONALCADASTRALZONINGREFERENCE
|| isSheetOrOverlayFeature(item.feature)))
.map(item => item.feature);
const parcelFeatures = [
...(lmeState.imported.toGeoJSON()?.features || []),
...(lmeState.selectedParcelsLayer?.toGeoJSON?.()?.features || [])
].filter(feature => ["Polygon", "MultiPolygon"].includes(feature?.geometry?.type));
const representatives = new Map();
parcelFeatures.forEach((feature, index) => {
const identity = cadastralSheetIdentity(feature, index);
if (!representatives.has(identity)) representatives.set(identity, feature);
});
if (!representatives.size || !lmeState.dotNetReference) {
return { added: 0, failed: false };
}
const existingKeys = new Set(existingAreaSheets.map(cadastralSheetFeatureKey));
const officialSheets = new Map();
let failures = 0;
for (const [identity, parcelFeature] of representatives) {
const point = sheetRepresentativePoint(parcelFeature);
if (!point) continue;
const existingSheet = findContainingPolygon(point, {
type: "FeatureCollection",
features: [...existingAreaSheets, ...officialSheets.values()]
});
if (existingSheet) continue;
try {
const xml = await lmeState.dotNetReference.invokeMethodAsync(
"GetOfficialCadastralSheetsNear",
point[1],
point[0]);
const candidates = gmlParcelsToGeoJson(xml).features || [];
const containing = findContainingPolygon(point, { type: "FeatureCollection", features: candidates });
const sheet = containing || candidates[0];
if (!sheet) continue;
const key = cadastralSheetFeatureKey(sheet);
if (existingKeys.has(key) || officialSheets.has(key)) continue;
const reference = String(sheet.properties?.NATIONALCADASTRALZONINGREFERENCE
|| sheet.properties?.LABEL
|| identity.split("|").pop()
|| "").trim();
sheet.properties = {
...(sheet.properties || {}),
name: reference ? `Foglio catastale ${reference}` : "Foglio catastale",
foglio: reference,
_lmeSheetIdentity: identity,
_lmeOfficialSheet: true,
_lmeSource: reference ? `Foglio catastale ${reference}` : "Fogli catastali AdE",
_lmeGroupName: reference ? `Foglio ${reference}` : "Fogli catastali AdE"
};
officialSheets.set(key, sheet);
} catch {
failures++;
}
}
if (officialSheets.size) {
addImportedSheetFeatures({ type: "FeatureCollection", features: [...officialSheets.values()] });
applyImportedSheetVisibility();
refreshLayerManager();
finishMapChange(`${officialSheets.size} fogli catastali ufficiali caricati.`);
}
return { added: officialSheets.size, failed: failures > 0 && !officialSheets.size };
}
function migrateMisclassifiedImportedSheets() {
if (!lmeState.imported || !lmeState.importedSheets) {
return 0;
}
const current = lmeState.imported.toGeoJSON();
const misplaced = (current.features || []).filter(isSheetOrOverlayFeature);
if (!misplaced.length) {
return 0;
}
const retained = (current.features || []).filter(feature => !isSheetOrOverlayFeature(feature));
lmeState.imported.clearLayers();
addImportedFeatures({ type: "FeatureCollection", features: retained });
addImportedSheetFeatures({ type: "FeatureCollection", features: misplaced });
refreshImportedMeasurements();
return misplaced.length;
}
function addImportedFeatures(geojson) {
const layer = L.geoJSON(geojson || emptyFeatureCollection(), {
pane: "importedPane",
style: { color: "#22c55e", weight: 3, opacity: 0.95, fillOpacity: 0.18 },
pointToLayer: (feature, latlng) => L.circleMarker(latlng, {
pane: "importedPane",
radius: 7,
color: "#22c55e",
fillColor: "#ffffff",
fillOpacity: 0.9
}),
onEachFeature: (feature, layer) => { bindStoredFeaturePopup(feature, layer); applyStyleFromProperties(layer); bindManagedMapSelection(layer, "imported", lmeState.imported); }
}).addTo(lmeState.imported);
layer.lmeManagerLabel = managedGeoJsonLabel(geojson, "Dati importati");
return layer;
}
function addImportedSheetFeatures(geojson) {
const layer = L.geoJSON(geojson || emptyFeatureCollection(), {
pane: "importedSheetPane",
style: { color: "#f59e0b", weight: 4, opacity: 1, fillOpacity: 0.03, dashArray: "10 6" },
pointToLayer: (feature, latlng) => L.circleMarker(latlng, {
pane: "importedSheetPane",
radius: 7,
color: "#f59e0b",
fillColor: "#ffffff",
fillOpacity: 0.85
}),
onEachFeature: (feature, layer) => { bindStoredFeaturePopup(feature, layer); applyStyleFromProperties(layer); bindManagedMapSelection(layer, "importedSheets", lmeState.importedSheets); }
}).addTo(lmeState.importedSheets);
layer.lmeManagerLabel = managedGeoJsonLabel(geojson, "Cornice o foglio");
return layer;
}
function applyImportedSheetVisibility() {
if (!lmeState.map || !lmeState.importedSheets) {
return;
}
if (lmeState.importedSheetsVisible) {
if (!lmeState.map.hasLayer(lmeState.importedSheets)) {
lmeState.importedSheets.addTo(lmeState.map);
}
lmeState.importedSheets.bringToFront();
return;
}
if (lmeState.map.hasLayer(lmeState.importedSheets)) {
lmeState.map.removeLayer(lmeState.importedSheets);
}
}
function setImportedMeasurementsVisible(enabled) {
lmeState.showImportedMeasurements = Boolean(enabled);
refreshImportedMeasurements();
refreshLayerManager();
setInfo(lmeState.showImportedMeasurements
? "Misure dei file GIS visibili."
: "Misure dei file GIS nascoste.");
}
function setDrawingMeasurementsVisible(enabled) {
lmeState.showDrawingMeasurements = Boolean(enabled);
refreshDrawingMeasurements();
refreshLayerManager();
setInfo(lmeState.showDrawingMeasurements
? "Misure dei disegni visibili."
: "Misure dei disegni nascoste.");
}
function fitImported() {
const layers = [lmeState.imported];
if (lmeState.importedSheetsVisible) {
layers.push(lmeState.importedSheets);
}
fitLayer(L.featureGroup(layers));
}
function clearImported() {
rememberMapChange();
clearManagedCategoryState("imported");
clearManagedCategoryState("importedSheets");
lmeState.imported.clearLayers();
lmeState.importedSheets.clearLayers();
lmeState.importedSheetsVisible = false;
applyImportedSheetVisibility();
lmeState.importedMeasurements.clearLayers();
setInfo("File GIS rimossi.");
updateGpsInsideState();
refreshLayerManager();
finishMapChange();
}
function addProjectMarker(name) {
if (!canAddDrawingObjects()) return false;
rememberMapChange();
const center = lmeState.map.getCenter();
const label = (name || "Nuovo POI").trim();
const marker = L.marker(center, { pane: "drawingsMarkerPane", draggable: false }).addTo(lmeState.drawings);
marker.lmeManagerLabel = `POI: ${label}`;
marker.feature = {
type: "Feature",
properties: { name: label }
};
marker.bindPopup(`<strong>${escapeHtml(label)}</strong><br>${center.lat.toFixed(6)}, ${center.lng.toFixed(6)}`).openPopup();
bindDrawingLayerSelection(marker);
refreshDrawingMeasurements();
refreshLayerManager();
selectDrawingLayer(marker);
finishMapChange();
return true;
}
function configureDrawLabels() {
L.drawLocal.draw.toolbar.buttons.polygon = lmeState.labels.measureArea;
L.drawLocal.draw.toolbar.buttons.polyline = lmeState.labels.measureDistance;
L.drawLocal.draw.toolbar.buttons.circle = lmeState.labels.measureCircle;
L.drawLocal.draw.toolbar.buttons.rectangle = lmeState.labels.drawRectangle;
L.drawLocal.draw.toolbar.buttons.marker = lmeState.labels.addPoi;
L.drawLocal.edit.toolbar.buttons.edit = "Scegli quali disegni modificare";
L.drawLocal.edit.toolbar.buttons.remove = "Scegli quali disegni eliminare";
L.drawLocal.draw.handlers.polygon.tooltip.start = "Tocca il primo vertice dell'area.";
L.drawLocal.draw.handlers.polygon.tooltip.cont = "Tocca per aggiungere un vertice.";
L.drawLocal.draw.handlers.polygon.tooltip.end = "Tocca il primo punto per chiudere l'area.";
L.drawLocal.draw.handlers.polyline.tooltip.start = "Tocca il primo punto della distanza.";
L.drawLocal.draw.handlers.polyline.tooltip.cont = "Tocca per aggiungere un tratto.";
L.drawLocal.draw.handlers.polyline.tooltip.end = "Tocca l'ultimo punto per terminare.";
L.drawLocal.draw.handlers.circle.tooltip.start = "Tocca il centro e trascina per il raggio.";
}
function bindDrawingEvents() {
if (!lmeState.map || lmeState.drawEventsBound) {
return;
}
lmeState.map.on(L.Draw.Event.CREATED, event => {
if (lmeState.readOnly) {
setInfo("Il progetto e' in sola lettura.");
return;
}
if (!canAddDrawingObjects()) return;
snapDrawingLayer(event.layer);
annotateDrawingLayer(event.layer, event.layerType);
lmeState.drawings.addLayer(event.layer);
refreshDrawingMeasurements();
updateGpsInsideState();
const completedLabels = {
polygon: "Area completata: superficie, perimetro e lati aggiornati.",
rectangle: "Rettangolo completato: superficie, perimetro e lati aggiornati.",
polyline: "Distanza completata: lunghezza totale e tratti aggiornati.",
circle: "Cerchio completato: superficie, raggio e circonferenza aggiornati.",
marker: "Punto aggiunto alla mappa."
};
setInfo(completedLabels[event.layerType] || "Disegno completato.");
refreshLayerManager();
selectDrawingLayer(event.layer);
finishMapChange();
});
lmeState.map.on(L.Draw.Event.DRAWSTART, rememberMapChange);
lmeState.map.on(L.Draw.Event.EDITSTART, rememberMapChange);
lmeState.map.on(L.Draw.Event.EDITED, event => {
event.layers.eachLayer(layer => { snapDrawingLayer(layer); annotateDrawingLayer(layer); });
refreshDrawingMeasurements();
updateGpsInsideState();
refreshLayerManager();
finishMapChange();
});
lmeState.map.on(L.Draw.Event.DELETED, event => {
event.layers?.eachLayer(layer => lmeState.drawings.removeLayer(layer));
clearManagerHighlight();
refreshDrawingMeasurements();
updateGpsInsideState();
refreshLayerManager();
finishMapChange();
});
lmeState.map.on(L.Draw.Event.DRAWSTOP, () => {
lmeState.activeDrawHandler = null;
});
lmeState.drawEventsBound = true;
}
function enableDrawTools() {
if (lmeState.drawControl) {
setInfo("Strumenti di disegno gia attivi.");
return;
}
configureDrawLabels();
const orangeArea = {
pane: "drawingsPane",
renderer: lmeState.drawingsRenderer,
color: "#f97316",
weight: 4,
fillColor: "#fb923c",
fillOpacity: 0.2
};
lmeState.drawControl = new L.Control.Draw({
position: "topright",
edit: {
featureGroup: lmeState.drawings,
remove: false,
edit: {
selectedPathOptions: {
color: "#ff00d4",
fillColor: "#00fff0",
fillOpacity: 0.16,
maintainColor: false
}
}
},
draw: {
circle: { metric: usesMetricMeasurements(), feet: usesImperialMeasurements(), showRadius: true, shapeOptions: orangeArea },
circlemarker: false,
marker: { zIndexOffset: 0 },
polygon: {
metric: usesMetricMeasurements(),
feet: usesImperialMeasurements(),
showArea: true,
allowIntersection: false,
shapeOptions: orangeArea
},
polyline: {
metric: usesMetricMeasurements(),
feet: usesImperialMeasurements(),
showLength: true,
shapeOptions: {
pane: "drawingsPane",
renderer: lmeState.drawingsRenderer,
color: "#f97316",
weight: 4,
opacity: 0.95
}
},
rectangle: { metric: usesMetricMeasurements(), feet: usesImperialMeasurements(), shapeOptions: orangeArea }
}
});
lmeState.map.addControl(lmeState.drawControl);
installDrawingScopeControls();
bindDrawingEvents();
setInfo("Barra strumenti attiva: seleziona un disegno sulla mappa, poi usa matita o cestino.");
}
function installDrawingScopeControls() {
const toolbar = drawingEditToolbar();
const container = toolbar?._toolbarContainer;
if (!container) {
return;
}
const editButton = toolbar._modes?.[L.EditToolbar.Edit.TYPE]?.button;
editButton?.addEventListener("click", event => {
event.preventDefault();
event.stopImmediatePropagation();
showDrawingScopeMenu(editButton, "edit");
}, true);
const button = L.DomUtil.create("a", "leaflet-draw-edit-remove", container);
button.href = "#";
button.title = L.drawLocal.edit.toolbar.buttons.remove;
button.setAttribute("role", "button");
button.setAttribute("aria-label", L.drawLocal.edit.toolbar.buttons.remove);
const accessibleLabel = L.DomUtil.create("span", "sr-only", button);
accessibleLabel.textContent = L.drawLocal.edit.toolbar.buttons.remove;
L.DomEvent.on(button, "click", L.DomEvent.stop)
.on(button, "mousedown", L.DomEvent.stopPropagation)
.on(button, "touchstart", L.DomEvent.stopPropagation)
.on(button, "click", () => showDrawingScopeMenu(button, "delete"));
lmeState.drawingDeleteButton = button;
updateDrawingSelectionActions();
}
function showDrawingScopeMenu(button, action) {
if (drawingEditActive()) {
setInfo("Salva o annulla la modifica corrente prima di scegliere un altro comando.");
return;
}
if (!lmeState.drawings?.getLayers?.().length) {
setInfo("Non ci sono disegni o POI su cui operare.");
return;
}
if (lmeState.drawingScopeMenu?.dataset.action === action) {
hideDrawingScopeMenu();
return;
}
hideDrawingScopeMenu();
const menu = document.createElement("ul");
menu.className = "leaflet-draw-actions lme-drawing-scope-actions";
menu.dataset.action = action;
menu.style.display = "block";
menu.style.top = `${button.offsetTop - 1}px`;
menu.innerHTML = `<li><a href="#" data-drawing-scope="selected">Solo selezionato</a></li><li><a href="#" data-drawing-scope="all">Tutti</a></li>`;
button.closest(".leaflet-draw-section")?.appendChild(menu);
lmeState.drawingScopeMenu = menu;
const hasSelection = Boolean(lmeState.drawingSelection?.getLayers?.().length);
const selectedAction = menu.querySelector("[data-drawing-scope='selected']");
selectedAction.setAttribute("aria-disabled", String(!hasSelection));
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", () => runDrawingScopeAction(action, link.dataset.drawingScope));
});
}
function hideDrawingScopeMenu() {
lmeState.drawingScopeMenu?.remove();
lmeState.drawingScopeMenu = null;
}
function runDrawingScopeAction(action, scope) {
const targetGroup = scope === "selected" ? lmeState.drawingSelection : lmeState.drawings;
if (!targetGroup?.getLayers?.().length) {
setInfo("Seleziona prima un oggetto sulla mappa.");
return;
}
hideDrawingScopeMenu();
if (action === "edit") {
startScopedDrawingEdit(targetGroup, scope);
} else if (scope === "selected") {
removeSelectedDrawing();
} else {
clearDrawings();
}
}
function startScopedDrawingEdit(targetGroup, scope) {
const toolbar = drawingEditToolbar();
const mode = toolbar?._modes?.[L.EditToolbar.Edit.TYPE];
if (!mode?.handler) {
return;
}
const locked = targetGroup.getLayers().filter(layer => layer?.feature?.properties?._lmeLocked);
if (locked.length) { setInfo("Sblocca gli oggetti prima di modificarli."); return; }
rememberMapChange();
mode.handler._featureGroup = targetGroup;
mode.handler.options.featureGroup = targetGroup;
mode.handler.enable();
setInfo(scope === "selected"
? "Modifica attiva sul solo oggetto selezionato."
: "Modifica attiva su tutti i disegni e POI.");
}
function removeSelectedDrawing() {
const layer = lmeState.drawingSelection?.getLayers?.()[0];
if (!layer) {
setInfo("Seleziona prima il disegno da eliminare toccandolo sulla mappa.");
return;
}
if (drawingEditActive()) {
setInfo("Salva o annulla la modifica prima di eliminare il disegno.");
return;
}
if (layer.feature?.properties?._lmeLocked) { setInfo("Sblocca prima l'oggetto selezionato."); return; }
const record = {
id: managedLayerId(layer, "drawings"),
categoryKey: "drawings",
layer,
parent: lmeState.drawings
};
const name = managedObjectLabel(record, 0);
if (!window.confirm(`Eliminare solo '${name}' dal progetto?`)) {
return;
}
rememberMapChange();
lmeState.drawingSelection.clearLayers();
lmeState.drawings.removeLayer(layer);
clearManagedObjectState(record.id);
clearManagerHighlight();
refreshDrawingMeasurements();
updateGpsInsideState();
refreshLayerManager();
finishMapChange();
setInfo(`'${name}' eliminato dal progetto.`);
}
function startMeasurementDrawing(mode) {
if (!lmeState.map) {
return;
}
if (!canAddDrawingObjects()) return;
bindDrawingEvents();
hideDrawingScopeMenu();
if (lmeState.activeDrawHandler) {
lmeState.activeDrawHandler.disable();
}
const handlers = {
area: () => new L.Draw.Polygon(lmeState.map, {
allowIntersection: false,
showArea: true,
metric: usesMetricMeasurements(),
feet: usesImperialMeasurements(),
shapeOptions: {
pane: "drawingsPane",
renderer: lmeState.drawingsRenderer,
color: "#f97316",
weight: 4,
fillColor: "#fb923c",
fillOpacity: 0.2
}
}),
distance: () => new L.Draw.Polyline(lmeState.map, {
metric: usesMetricMeasurements(),
feet: usesImperialMeasurements(),
showLength: true,
shapeOptions: {
pane: "drawingsPane",
renderer: lmeState.drawingsRenderer,
color: "#f97316",
weight: 4,
opacity: 0.95
}
}),
circle: () => new L.Draw.Circle(lmeState.map, {
metric: usesMetricMeasurements(),
feet: usesImperialMeasurements(),
showRadius: true,
shapeOptions: {
pane: "drawingsPane",
renderer: lmeState.drawingsRenderer,
color: "#f97316",
weight: 4,
fillColor: "#fb923c",
fillOpacity: 0.2
}
})
};
const createHandler = handlers[mode];
if (!createHandler) {
setInfo("Modalita di misura non riconosciuta.");
return;
}
lmeState.activeDrawHandler = createHandler();
lmeState.activeDrawHandler.enable();
const labels = {
area: "Tocca i vertici dell'area; chiudi toccando il primo punto.",
distance: "Tocca i punti del percorso; termina sull'ultimo punto.",
circle: "Tocca il centro e trascina fino al raggio desiderato."
};
setInfo(labels[mode]);
}
function annotateDrawingLayer(layer, layerType) {
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();
} else if (layerType) {
layer.feature.properties._lmeShape = layerType;
}
layer.lmeManagerLabel = drawingLayerLabel(layer, layerType);
bindDrawingLayerSelection(layer);
}
function bindDrawingLayerSelection(layer) {
if (layer.lmeDrawingSelectionBound) {
return;
}
layer.lmeDrawingSelectionBound = true;
layer.on("click", event => {
if (drawingEditActive() || lmeState.selectionMode === "off") {
return;
}
lmeState.ignoreMapClickUntil = Date.now() + 1000;
if (event.originalEvent) {
event.originalEvent._lmeDrawingSelected = true;
L.DomEvent.stopPropagation(event.originalEvent);
}
const record = {
id: managedLayerId(layer, "drawings"),
categoryKey: "drawings",
layer,
parent: lmeState.drawings,
source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente")
};
selectManagedRecord(record, event.originalEvent);
});
}
function bindManagedMapSelection(layer, categoryKey, parent) {
if (layer.lmeManagedMapSelectionBound) return;
layer.lmeManagedMapSelectionBound = true;
layer.on("click", event => {
if (Date.now() < lmeState.ignoreMapClickUntil || lmeState.selectionMode === "off") return;
lmeState.ignoreMapClickUntil = Date.now() + 600;
if (event.originalEvent) {
event.originalEvent._lmeDrawingSelected = true;
L.DomEvent.stopPropagation(event.originalEvent);
}
const record = { id: managedLayerId(layer, categoryKey), categoryKey, layer, parent, source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente") };
selectManagedRecord(record, event.originalEvent);
});
}
function setDrawToolsVisible(visible) {
if (visible) {
enableDrawTools();
return;
}
if (!lmeState.drawControl) {
return;
}
Object.values(lmeState.drawControl._toolbars || {}).forEach(toolbar => {
if (typeof toolbar.disable === "function") {
toolbar.disable();
}
});
lmeState.map.removeControl(lmeState.drawControl);
lmeState.drawControl = null;
lmeState.drawingDeleteButton = null;
lmeState.drawingScopeMenu = null;
setInfo("Strumenti di disegno nascosti.");
}
function clearDrawings() {
const count = managedGroupGeoJson(lmeState.drawings, "drawings").features.length;
if (!count) {
setInfo("Non ci sono disegni o POI da eliminare.");
return;
}
if (!window.confirm(`Eliminare ${count} disegni e POI dalla mappa?`)) {
return;
}
if (managedRecordsForCategory(layerManagerCategories().find(item => item.key === "drawings")).some(record => record.layer?.feature?.properties?._lmeLocked)) {
setInfo("Sblocca tutti i disegni prima di eliminarli."); return;
}
rememberMapChange();
drawingEditToolbar()?.disable?.();
lmeState.drawingSelection?.clearLayers();
clearManagerHighlight();
lmeState.drawings.clearLayers();
clearManagedCategoryState("drawings");
lmeState.drawingMeasurements.clearLayers();
updateGpsInsideState();
setInfo("Disegni e POI eliminati dalla mappa.");
refreshLayerManager();
finishMapChange();
}
function snapDrawingLayer(layer) {
if (!lmeState.snapEnabled || !layer || layer instanceof L.Circle || layer instanceof L.Marker) return;
const candidates = [];
const segments = [];
layerManagerCategories().forEach(category => managedRecordsForCategory(category).forEach(record => {
if (record.layer === layer) return;
lineLatLngGroups(record.layer?.getLatLngs?.() || []).forEach(line => {
line.forEach(point => candidates.push(point));
for (let index = 1; index < line.length; index++) segments.push([line[index - 1], line[index]]);
if (record.layer instanceof L.Polygon && line.length > 2) segments.push([line.at(-1), line[0]]);
});
const point = record.layer?.getLatLng?.(); if (point) candidates.push(point);
}));
const snapPoint = point => {
const pixel = lmeState.map.latLngToContainerPoint(point);
let nearest = null; let distance = lmeState.snapDistancePixels;
candidates.forEach(candidate => { const current = pixel.distanceTo(lmeState.map.latLngToContainerPoint(candidate)); if (current < distance) { distance = current; nearest = candidate; } });
segments.forEach(([start, end]) => {
const a = lmeState.map.latLngToContainerPoint(start); const b = lmeState.map.latLngToContainerPoint(end);
const lengthSquared = (b.x - a.x) ** 2 + (b.y - a.y) ** 2;
if (!lengthSquared) return;
const ratio = Math.max(0, Math.min(1, ((pixel.x - a.x) * (b.x - a.x) + (pixel.y - a.y) * (b.y - a.y)) / lengthSquared));
const projected = L.point(a.x + ratio * (b.x - a.x), a.y + ratio * (b.y - a.y));
const current = pixel.distanceTo(projected);
if (current < distance) { distance = current; nearest = lmeState.map.containerPointToLatLng(projected); }
const midpoint = L.point((a.x + b.x) / 2, (a.y + b.y) / 2);
const midpointDistance = pixel.distanceTo(midpoint);
if (midpointDistance < distance) { distance = midpointDistance; nearest = lmeState.map.containerPointToLatLng(midpoint); }
});
return nearest ? L.latLng(nearest.lat, nearest.lng) : point;
};
const transform = value => Array.isArray(value) ? value.map(transform) : snapPoint(value);
layer.setLatLngs(transform(layer.getLatLngs()));
}
function scheduleMeasurementRefresh() {
window.clearTimeout(lmeState.measurementRefreshTimer);
lmeState.measurementRefreshTimer = window.setTimeout(() => {
refreshImportedMeasurements();
refreshDrawingMeasurements();
}, 80);
}
function refreshImportedMeasurements() {
renderMeasurements(
lmeState.imported,
lmeState.importedMeasurements,
lmeState.showImportedMeasurements,
"imported");
}
function refreshDrawingMeasurements() {
renderMeasurements(
lmeState.drawings,
lmeState.drawingMeasurements,
lmeState.showDrawingMeasurements,
"drawing");
}
function renderMeasurements(sourceGroup, targetGroup, visible, sourceType) {
if (!sourceGroup || !targetGroup) {
return;
}
targetGroup.clearLayers();
if (!visible || !lmeState.map.hasLayer(sourceGroup)) {
return;
}
const context = {
count: 0,
max: 600,
showSides: lmeState.map.getZoom() >= 17,
bounds: lmeState.map.getBounds(),
sourceType
};
visitMeasurableLayers(sourceGroup, layer => {
const categoryKey = sourceType === "imported" ? "imported" : "drawings";
const objectId = managedLayerId(layer, categoryKey);
const showSummary = !lmeState.managerHiddenMeasurementSummaryIds.has(objectId);
const showSides = !lmeState.managerHiddenMeasurementSideIds.has(objectId);
if (!showSummary && !showSides) {
return;
}
if (context.count >= context.max || !layerIsInView(layer, context.bounds)) {
return;
}
const layerContext = {
...context,
showSummary,
showSides: context.showSides && showSides
};
if (layer instanceof L.Circle) {
renderCircleMeasurement(layer, targetGroup, layerContext);
} else if (layer instanceof L.Polygon) {
renderPolygonMeasurement(layer, targetGroup, layerContext);
} else if (layer instanceof L.Polyline) {
renderPolylineMeasurement(layer, targetGroup, layerContext);
}
});
}
function visitMeasurableLayers(layer, visitor) {
if (layer instanceof L.Circle || layer instanceof L.Polygon || layer instanceof L.Polyline) {
visitor(layer);
return;
}
if (typeof layer.eachLayer === "function") {
layer.eachLayer(child => visitMeasurableLayers(child, visitor));
}
}
function layerIsInView(layer, mapBounds) {
if (typeof layer.getBounds === "function") {
const bounds = layer.getBounds();
return !bounds.isValid || !bounds.isValid() || bounds.intersects(mapBounds);
}
if (typeof layer.getLatLng === "function") {
return mapBounds.contains(layer.getLatLng());
}
return true;
}
function renderCircleMeasurement(layer, targetGroup, context) {
if (!context.showSummary) {
return;
}
const radius = layer.getRadius();
const area = Math.PI * radius * radius;
const circumference = 2 * Math.PI * radius;
addMeasurementLabel(
targetGroup,
layer.getLatLng(),
`S: ${formatArea(area)}<br>R: ${formatDistance(radius)} · C: ${formatDistance(circumference)}`,
"summary",
context);
}
function renderPolygonMeasurement(layer, targetGroup, context) {
const polygons = polygonLatLngGroups(layer.getLatLngs());
let area = 0;
let perimeter = 0;
polygons.forEach(rings => {
rings.forEach((ring, index) => {
const ringArea = Math.abs(L.GeometryUtil.geodesicArea(ring));
area += index === 0 ? ringArea : -ringArea;
perimeter += latLngPathLength(ring, true);
if (context.showSides) {
addSegmentLabels(ring, true, targetGroup, context);
}
});
});
const center = typeof layer.getCenter === "function"
? layer.getCenter()
: layer.getBounds().getCenter();
if (context.showSummary) {
addMeasurementLabel(
targetGroup,
center,
`S: ${formatArea(Math.max(0, area))}<br>P: ${formatDistance(perimeter)}`,
"summary",
context);
}
}
function renderPolylineMeasurement(layer, targetGroup, context) {
const lines = lineLatLngGroups(layer.getLatLngs());
const length = lines.reduce((total, line) => total + latLngPathLength(line, false), 0);
if (context.showSides) {
lines.forEach(line => addSegmentLabels(line, false, targetGroup, context));
}
const longest = lines.reduce((current, line) => line.length > current.length ? line : current, []);
if (context.showSummary && longest.length) {
addMeasurementLabel(
targetGroup,
longest[Math.floor(longest.length / 2)],
`L: ${formatDistance(length)}`,
"summary",
context);
}
}
function polygonLatLngGroups(latLngs) {
if (!Array.isArray(latLngs) || !latLngs.length) {
return [];
}
if (isLatLng(latLngs[0])) {
return [[latLngs]];
}
if (Array.isArray(latLngs[0]) && isLatLng(latLngs[0][0])) {
return [latLngs];
}
return latLngs.flatMap(polygonLatLngGroups);
}
function lineLatLngGroups(latLngs) {
if (!Array.isArray(latLngs) || !latLngs.length) {
return [];
}
if (isLatLng(latLngs[0])) {
return [latLngs];
}
return latLngs.flatMap(lineLatLngGroups);
}
function isLatLng(value) {
return value && Number.isFinite(value.lat) && Number.isFinite(value.lng);
}
function latLngPathLength(points, closed) {
if (points.length < 2) {
return 0;
}
let total = 0;
for (let index = 1; index < points.length; index += 1) {
total += lmeState.map.distance(points[index - 1], points[index]);
}
if (closed && !points[0].equals(points[points.length - 1])) {
total += lmeState.map.distance(points[points.length - 1], points[0]);
}
return total;
}
function addSegmentLabels(points, closed, targetGroup, context) {
if (points.length < 2) {
return;
}
const segmentCount = closed ? points.length : points.length - 1;
for (let index = 0; index < segmentCount && context.count < context.max; index += 1) {
const start = points[index];
const end = points[(index + 1) % points.length];
if (start.equals(end)) {
continue;
}
addMeasurementLabel(
targetGroup,
L.latLng((start.lat + end.lat) / 2, (start.lng + end.lng) / 2),
formatDistance(lmeState.map.distance(start, end)),
"side",
context);
}
}
function addMeasurementLabel(targetGroup, latLng, html, labelType, context) {
if (context.count >= context.max) {
return;
}
L.marker(latLng, {
pane: "tooltipPane",
interactive: false,
keyboard: false,
icon: L.divIcon({
className: `lme-measure-label ${context.sourceType} ${labelType}`,
html: `<span>${html}</span>`,
iconSize: null
})
}).addTo(targetGroup).lmeManagerLabel = html.replace(/<[^>]+>/g, "");
context.count += 1;
}
function formatDistance(meters) {
if (usesImperialMeasurements()) {
const feet = meters * 3.280839895;
if (feet >= 5280) {
return `${formatNumber(feet / 5280, 3)} mi`;
}
return `${formatNumber(feet, feet < 100 ? 1 : 0)} ft`;
}
if (meters >= 1000) {
return `${formatNumber(meters / 1000, 3)} km`;
}
return `${formatNumber(meters, meters < 100 ? 1 : 0)} m`;
}
function formatArea(squareMeters) {
if (usesImperialMeasurements()) {
const squareFeet = squareMeters * 10.763910417;
const acres = squareMeters * 0.000247105381;
if (acres >= 1) {
return `${formatNumber(acres, 3)} ac`;
}
return `${formatNumber(squareFeet, squareFeet < 100 ? 1 : 0)} ft²`;
}
if (squareMeters >= 10000) {
return `${formatNumber(squareMeters / 10000, 4)} ha`;
}
return `${formatNumber(squareMeters, squareMeters < 100 ? 1 : 0)} m²`;
}
function formatNumber(value, digits) {
return new Intl.NumberFormat(lmeState.culture || "it-IT", {
minimumFractionDigits: 0,
maximumFractionDigits: digits
}).format(value);
}
function usesMetricMeasurements() {
return lmeState.measurementSystem !== "imperial";
}
function usesImperialMeasurements() {
return lmeState.measurementSystem === "imperial";
}
function createMapExportGeoJson(options) {
migrateMisclassifiedImportedSheets();
const requested = options || {};
const features = [];
if (requested.includeImported !== false) {
appendExportFeatures(features, managedGroupGeoJson(lmeState.imported, "imported"), "imported");
}
if (requested.includeImportedSheets !== false) {
appendExportFeatures(features, managedGroupGeoJson(lmeState.importedSheets, "importedSheets"), "imported-sheet");
}
if (requested.includeCadastral !== false) {
appendExportFeatures(features, managedGroupGeoJson(lmeState.selectedParcelsLayer, "selectedParcels"), "cadastral");
appendExportFeatures(features, managedGroupGeoJson(lmeState.gpsParcelLayer, "gpsParcels"), "cadastral-gps");
}
const drawings = managedGroupGeoJson(lmeState.drawings, "drawings");
if (requested.includeDrawings !== false) {
appendExportFeatures(features, drawings, "drawing", feature => !isPoiFeature(feature));
}
if (requested.includePoi !== false) {
appendExportFeatures(features, drawings, "poi", isPoiFeature);
}
if (requested.includeTrail !== false && lmeState.gpsTrailPoints.length >= 2) {
features.push({
type: "Feature",
properties: { name: "Traccia GPS", _lmeCategory: "gps-trail" },
geometry: {
type: "LineString",
coordinates: lmeState.gpsTrailPoints.map(point => [
Number(point.lng ?? point[1]),
Number(point.lat ?? point[0])
])
}
});
}
const exported = requested.selectedOnly
? features.filter(feature => lmeState.managerSelectedObjectIds.has(feature?.properties?._lmeManagerId))
: features;
return JSON.stringify({ type: "FeatureCollection", features: exported });
}
function appendExportFeatures(target, collection, category, predicate = () => true) {
for (const sourceFeature of collection?.features || []) {
if (!sourceFeature?.geometry || !predicate(sourceFeature)) {
continue;
}
const feature = JSON.parse(JSON.stringify(sourceFeature));
feature.properties = { ...(feature.properties || {}), _lmeCategory: category };
if (feature.properties._lmeShape === "circle"
&& feature.geometry.type === "Point"
&& Number(feature.properties._lmeRadius) > 0) {
feature.geometry = circleToPolygon(feature.geometry.coordinates, Number(feature.properties._lmeRadius));
}
target.push(feature);
}
}
function isPoiFeature(feature) {
return feature?.geometry?.type === "Point"
&& feature?.properties?._lmeShape !== "circle";
}
function circleToPolygon(center, radiusMeters) {
const longitude = Number(center?.[0]);
const latitude = Number(center?.[1]);
const earthRadius = 6378137;
const latitudeRadians = latitude * Math.PI / 180;
const ring = [];
for (let index = 0; index <= 64; index += 1) {
const angle = index / 64 * Math.PI * 2;
const north = Math.sin(angle) * radiusMeters;
const east = Math.cos(angle) * radiusMeters;
ring.push([
longitude + east / (earthRadius * Math.cos(latitudeRadians)) * 180 / Math.PI,
latitude + north / earthRadius * 180 / Math.PI
]);
}
return { type: "Polygon", coordinates: [ring] };
}
function createProjectSnapshot(includeCadastralParcels) {
const center = lmeState.map.getCenter();
return JSON.stringify({
schemaVersion: 7,
view: {
latitude: center.lat,
longitude: center.lng,
zoom: lmeState.map.getZoom()
},
drawings: managedGroupGeoJson(lmeState.drawings, "drawings"),
imported: managedGroupGeoJson(lmeState.imported, "imported"),
importedSheets: managedGroupGeoJson(lmeState.importedSheets, "importedSheets"),
importedSheetsVisible: lmeState.importedSheetsVisible,
fiducials: managedGroupGeoJson(lmeState.fiducials, "fiducials"),
displayOptions: captureDisplayOptions(),
selectedParcels: includeCadastralParcels
? managedGroupGeoJson(lmeState.selectedParcelsLayer, "selectedParcels")
: { type: "FeatureCollection", features: [] },
trail: lmeState.gpsTrailPoints.map(point => [
Number(point.lat ?? point[0]),
Number(point.lng ?? point[1])
]),
trailStartedAt: lmeState.gpsTrailStartedAt,
auditLog: [...lmeState.auditLog]
});
}
function getLegacyProjectSnapshot() {
return localStorage.getItem("lmeMapWorkspace");
}
function clearLegacyProjectSnapshot() {
localStorage.removeItem("lmeMapWorkspace");
}
function loadProjectSnapshot(raw, projectName) {
try {
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!data || typeof data !== "object") {
throw new Error("contenuto non valido");
}
resetLayerManagerProjectState();
lmeState.drawings.clearLayers();
lmeState.imported.clearLayers();
lmeState.importedSheets.clearLayers();
lmeState.drawingMeasurements.clearLayers();
lmeState.importedMeasurements.clearLayers();
lmeState.selectedParcelsLayer.clearLayers();
lmeState.fiducials.clearLayers();
lmeState.gpsParcelLayer.clearLayers();
const restoredDrawings = L.geoJSON(data.drawings || emptyFeatureCollection(), {
pane: "drawingsPane",
style: storedFeatureStyle,
pointToLayer: (feature, latlng) => {
const properties = feature?.properties || {};
if (properties._lmeShape === "circle" && Number(properties._lmeRadius) > 0) {
return L.circle(latlng, {
pane: "drawingsPane",
renderer: lmeState.drawingsRenderer,
radius: Number(properties._lmeRadius),
color: "#f97316",
weight: 3,
fillColor: "#fb923c",
fillOpacity: 0.2
});
}
return L.marker(latlng, { pane: "drawingsMarkerPane", draggable: false });
},
onEachFeature: (feature, layer) => {
bindStoredFeaturePopup(feature, layer);
annotateDrawingLayer(layer, feature?.properties?._lmeShape);
applyStyleFromProperties(layer);
}
});
restoredDrawings.eachLayer(layer => lmeState.drawings.addLayer(layer));
const importedParts = partitionStoredImportedFeatures(
data.imported || emptyFeatureCollection(),
data.importedSheets || emptyFeatureCollection());
addImportedFeatures(importedParts.parcels);
addImportedSheetFeatures(importedParts.sheets);
const projectDisplayOptions = data.schemaVersion >= 3 && data.displayOptions
? data.displayOptions
: null;
lmeState.importedSheetsVisible = projectDisplayOptions
? Boolean(projectDisplayOptions.importedSheetsVisible)
: data.schemaVersion >= 2 && Boolean(data.importedSheetsVisible);
applyImportedSheetVisibility();
const restoredParcels = L.geoJSON(data.selectedParcels || emptyFeatureCollection(), {
pane: "selectedParcelsPane",
style: feature => ({
color: feature?.properties?._lmeColor || "#ef4444",
weight: Number(feature?.properties?._lmeWeight) || 4,
opacity: Number.isFinite(Number(feature?.properties?._lmeOpacity)) ? Number(feature.properties._lmeOpacity) : 1,
fillColor: feature?.properties?._lmeFillColor || "#fbbf24",
fillOpacity: Number.isFinite(Number(feature?.properties?._lmeFillOpacity)) ? Number(feature.properties._lmeFillOpacity) : 0.26,
dashArray: feature?.properties?._lmeDash || null
}),
onEachFeature: (feature, layer) => {
const properties = feature?.properties || {};
const reference = properties.NATIONALCADASTRALREFERENCE
|| properties.INSPIREID_LOCALID
|| properties.LABEL
|| "Particella catastale";
layer.lmeParcelReference = reference;
layer.bindPopup(`<strong>${escapeHtml(reference)}</strong>`);
applyStyleFromProperties(layer);
}
}).addTo(lmeState.selectedParcelsLayer);
restoredParcels.eachLayer(layer => {
const properties = layer?.feature?.properties || {};
const reference = properties.NATIONALCADASTRALREFERENCE
|| properties.INSPIREID_LOCALID
|| properties.LABEL
|| "Particella catastale";
layer.lmeParcelReference = reference;
bindManagedMapSelection(layer, "selectedParcels", restoredParcels);
});
const restoredFiducials = L.geoJSON(data.fiducials || emptyFeatureCollection(), {
pointToLayer: (feature, latlng) => createFiducialMarker(feature, latlng),
onEachFeature: (feature, layer) => {
bindFiducialPopup(feature, layer);
bindManagedMapSelection(layer, "fiducials", lmeState.fiducials);
}
});
restoredFiducials.eachLayer(layer => lmeState.fiducials.addLayer(layer));
if (lmeState.importedSheetsVisible) {
void ensureImportedSheetOutlines().then(() => {
applyImportedSheetVisibility();
refreshLayerManager();
});
}
lmeState.gpsTrailPoints = Array.isArray(data.trail) ? data.trail : [];
lmeState.gpsTrailStartedAt = data.trailStartedAt || null;
lmeState.auditLog = Array.isArray(data.auditLog) ? data.auditLog.slice(-250) : [];
lmeState.gpsTrail.setLatLngs(lmeState.gpsTrailPoints);
if (projectDisplayOptions) {
applyProjectDisplayOptions(projectDisplayOptions);
}
refreshImportedMeasurements();
refreshDrawingMeasurements();
refreshPermanentLabels();
const projectLayerList = [
lmeState.drawings,
lmeState.imported,
lmeState.selectedParcelsLayer,
lmeState.fiducials,
lmeState.gpsTrail
];
if (lmeState.importedSheetsVisible) {
projectLayerList.push(lmeState.importedSheets);
}
const projectLayers = L.featureGroup(projectLayerList);
if (projectLayers.getBounds().isValid()) {
fitLayer(projectLayers);
} else if (Number.isFinite(data.view?.latitude)
&& Number.isFinite(data.view?.longitude)
&& Number.isFinite(data.view?.zoom)) {
lmeState.map.setView([data.view.latitude, data.view.longitude], data.view.zoom);
}
const parcelCount = lmeState.selectedParcelsLayer.toGeoJSON().features?.length || 0;
setText("parcelState", parcelCount
? `${parcelCount} particelle del progetto`
: "Nessuna particella selezionata");
updateGpsInsideState();
const hiddenSheets = importedParts.sheets.features.length && !lmeState.importedSheetsVisible
? ` ${importedParts.sheets.features.length} elementi del foglio sono nascosti.`
: "";
setInfo(`Progetto caricato: ${projectName || "senza nome"}.${hiddenSheets}`);
refreshLayerManager();
if (!lmeState.historyBusy) {
lmeState.isDirty = false;
lmeState.undoStack = [];
lmeState.redoStack = [];
void lmeState.dotNetReference?.invokeMethodAsync("OnProjectSaved");
}
return captureDisplayOptions();
} catch (error) {
setInfo(`Progetto non leggibile: ${error.message}`);
throw error;
}
}
function captureDisplayOptions() {
return {
baseLayer: lmeState.currentBaseName,
cadastreOpacity: lmeState.cadastreOpacity,
cadastreLayers: [...lmeState.cadastreLayers],
includeKmlSheetLayer: lmeState.includeKmlSheetLayer,
importedVisible: lmeState.importedVisible,
importedSheetsVisible: lmeState.importedSheetsVisible,
showImportedMeasurements: lmeState.showImportedMeasurements,
showDrawingMeasurements: lmeState.showDrawingMeasurements,
drawToolsVisible: Boolean(lmeState.drawControl),
gpsPositionVisible: lmeState.gpsPositionVisible,
compassVisible: lmeState.compassVisible,
compassMode: lmeState.compassMode,
selectedParcelsVisible: lmeState.selectedParcelsVisible,
fiducialsVisible: lmeState.fiducialsVisible,
fiducialColor: lmeState.fiducialColor,
layerOrder: [...lmeState.layerOrder],
layerObjectOrder: { ...lmeState.managerObjectOrder },
layerCategoryNames: { ...lmeState.managerCategoryNames },
removedLayerCategories: [...lmeState.managerRemovedCategories],
hiddenLayerObjects: [...lmeState.managerHiddenObjectIds],
hiddenLayerMeasurementSummaries: [...lmeState.managerHiddenMeasurementSummaryIds],
hiddenLayerMeasurementSides: [...lmeState.managerHiddenMeasurementSideIds],
hiddenLayerCategories: [...lmeState.managerHiddenCategories],
trailEnabled: lmeState.gpsTrailEnabled
};
}
function applyProjectDisplayOptions(options) {
const supportedCadastreLayers = new Set([
"CP.CadastralParcel",
"codice_plla",
"CP.CadastralZoning",
"fabbricati",
"strade",
"acque"
]);
if (Array.isArray(options.cadastreLayers)) {
lmeState.cadastreLayers = options.cadastreLayers.filter(layer => supportedCadastreLayers.has(layer));
}
const opacity = Number(options.cadastreOpacity);
if (Number.isFinite(opacity)) {
lmeState.cadastreOpacity = Math.max(0, Math.min(1, opacity));
}
lmeState.includeKmlSheetLayer = Boolean(options.includeKmlSheetLayer);
lmeState.importedVisible = options.importedVisible !== false;
lmeState.importedSheetsVisible = Boolean(options.importedSheetsVisible);
lmeState.showImportedMeasurements = Boolean(options.showImportedMeasurements);
lmeState.showDrawingMeasurements = options.showDrawingMeasurements !== false;
if (Array.isArray(options.layerOrder)) {
setLayerOrder(options.layerOrder);
}
if (options.layerObjectOrder && typeof options.layerObjectOrder === "object") {
lmeState.managerObjectOrder = options.layerObjectOrder;
}
if (options.layerCategoryNames && typeof options.layerCategoryNames === "object") {
lmeState.managerCategoryNames = { ...options.layerCategoryNames };
}
if (Array.isArray(options.removedLayerCategories)) {
lmeState.managerRemovedCategories = new Set(options.removedLayerCategories);
}
if (Array.isArray(options.hiddenLayerObjects)) {
lmeState.managerHiddenObjectIds = new Set(options.hiddenLayerObjects);
}
if (Array.isArray(options.hiddenLayerMeasurementSummaries)) {
lmeState.managerHiddenMeasurementSummaryIds = new Set(options.hiddenLayerMeasurementSummaries);
}
if (Array.isArray(options.hiddenLayerMeasurementSides)) {
lmeState.managerHiddenMeasurementSideIds = new Set(options.hiddenLayerMeasurementSides);
}
if (Array.isArray(options.hiddenLayerCategories)) {
lmeState.managerHiddenCategories = new Set(options.hiddenLayerCategories);
}
applyImportedSheetVisibility();
setImportedLayerVisible(lmeState.importedVisible);
refreshCadastreLayer();
if (typeof options.baseLayer === "string") {
setBaseLayer(options.baseLayer);
}
setGpsPositionVisible(options.gpsPositionVisible !== false);
const compassVisible = Boolean(options.compassVisible)
&& !lmeState.managerHiddenCategories.has("compass");
setCompassOptions(compassVisible, options.compassMode);
setSelectedParcelsVisible(options.selectedParcelsVisible !== false);
if (typeof options.fiducialColor === "string") {
setFiducialColor(options.fiducialColor);
}
setFiducialPointsVisible(options.fiducialsVisible !== false);
toggleTrail(Boolean(options.trailEnabled));
setDrawToolsVisible(Boolean(options.drawToolsVisible));
applyStoredLayerManagerVisibility();
saveLayerManagerState();
}
function partitionStoredImportedFeatures(imported, storedSheets) {
const parcelFeatures = [];
const sheetFeatures = [...(storedSheets?.features || [])];
const sourceGroups = new Map();
(imported?.features || []).forEach(feature => {
if (isSheetOrOverlayFeature(feature)) {
sheetFeatures.push(feature);
return;
}
const source = String(feature?.properties?._lmeSource || "");
if (!/\.(kml|kmz)$/i.test(source)) {
parcelFeatures.push(feature);
return;
}
const group = sourceGroups.get(source) || [];
group.push(feature);
sourceGroups.set(source, group);
});
sourceGroups.forEach(features => {
const polygonParts = splitKmlPolygonFeatures(features);
const parcels = filterKmlParcelFeatures(polygonParts);
const parcelSet = new Set(parcels);
parcelFeatures.push(...parcels);
sheetFeatures.push(...polygonParts.filter(feature => !parcelSet.has(feature)));
sheetFeatures.push(...features.filter(feature => geometryPolygons(feature.geometry).length === 0));
});
return {
parcels: { type: "FeatureCollection", features: parcelFeatures },
sheets: { type: "FeatureCollection", features: sheetFeatures }
};
}
function emptyFeatureCollection() {
return { type: "FeatureCollection", features: [] };
}
function managedGroupGeoJson(group, categoryKey) {
const collection = group?.toGeoJSON?.() || emptyFeatureCollection();
const features = [...(collection.features || [])];
const knownIds = new Set(features.map(feature => feature?.properties?._lmeManagerId).filter(Boolean));
lmeState.managerHiddenObjects.forEach(record => {
if (record.categoryKey !== categoryKey || typeof record.layer?.toGeoJSON !== "function") return;
const stored = record.layer.toGeoJSON();
const hiddenFeatures = stored?.type === "FeatureCollection" ? stored.features : [stored];
hiddenFeatures.filter(feature => feature?.type === "Feature").forEach(feature => {
const id = feature?.properties?._lmeManagerId;
if (!id || !knownIds.has(id)) {
features.push(feature);
if (id) knownIds.add(id);
}
});
});
const categoryName = lmeState.managerCategoryNames[categoryKey];
if (categoryName) {
features.forEach(feature => {
feature.properties = { ...(feature.properties || {}), _lmeCategoryName: categoryName };
});
}
return { type: "FeatureCollection", features };
}
function resetLayerManagerProjectState() {
drawingEditToolbar()?.disable?.();
lmeState.drawingSelection?.clearLayers();
lmeState.managerHiddenObjects.clear();
lmeState.managerHiddenObjectIds.clear();
lmeState.managerHiddenMeasurementSummaryIds.clear();
lmeState.managerHiddenMeasurementSideIds.clear();
lmeState.managerHiddenCategories.clear();
lmeState.managerRemovedCategories.clear();
lmeState.managerObjectOrder = {};
lmeState.managerCategoryNames = {};
lmeState.managerSelectedObjectIds.clear();
lmeState.permanentLabels?.clearLayers();
clearManagerHighlight();
}
function clearManagedCategoryState(categoryKey) {
clearManagedCategorySelection(categoryKey);
const category = layerManagerCategories().find(item => item.key === categoryKey);
if (category) {
managedRecordsForCategory(category).forEach(record => {
lmeState.managerHiddenMeasurementSummaryIds.delete(record.id);
lmeState.managerHiddenMeasurementSideIds.delete(record.id);
});
}
[...lmeState.managerHiddenObjects.entries()].forEach(([id, record]) => {
if (record.categoryKey === categoryKey) {
lmeState.managerHiddenObjects.delete(id);
lmeState.managerHiddenObjectIds.delete(id);
}
});
lmeState.managerHiddenCategories.delete(categoryKey);
delete lmeState.managerObjectOrder[categoryKey];
}
function clearManagedCategorySelection(categoryKey) {
const ids = new Set();
lmeState.managerRecords?.forEach?.(record => {
if (record.categoryKey === categoryKey) ids.add(record.id);
});
const category = layerManagerCategories().find(item => item.key === categoryKey);
if (category) managedRecordsForCategory(category).forEach(record => ids.add(record.id));
ids.forEach(id => lmeState.managerSelectedObjectIds.delete(id));
if (lmeState.managerHighlightedCategoryKey === categoryKey) {
lmeState.managerHighlightedObjectId = null;
lmeState.managerHighlightedCategoryKey = null;
}
restoreDirectManagerHighlights();
}
function bindStoredFeaturePopup(feature, layer) {
updateStoredFeaturePopup(layer, feature?.properties);
}
function updateStoredFeaturePopup(layer, sourceProperties) {
const properties = sourceProperties || layer?.feature?.properties || {};
if (properties._lmeFiducialSheetKey) {
bindFiducialPopup({ type: "Feature", properties }, layer);
return;
}
const label = properties.name || properties.Name || properties.label || properties.LABEL;
const notes = managedNotes(properties);
const photo = String(properties._lmePhoto || "");
if (label || notes.length || photo) {
const image = photo.startsWith("data:image/") ? `<img class="lme-popup-photo" src="${photo}" alt="Foto allegata">` : "";
const noteList = managedNotesPopupMarkup(properties);
layer.bindPopup(`${label ? `<strong>${escapeHtml(label)}</strong>` : ""}${noteList}${image}`);
return;
}
if (typeof layer?.unbindPopup === "function") {
layer.unbindPopup();
}
}
function offlineDatabase() {
if (lmeState.offlineDbPromise) return lmeState.offlineDbPromise;
lmeState.offlineDbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open("lmeOfflineMaps", 1);
request.onupgradeneeded = () => request.result.createObjectStore("tiles", { keyPath: "key" });
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return lmeState.offlineDbPromise;
}
async function offlineTileGet(key) {
const db = await offlineDatabase();
return new Promise((resolve, reject) => {
const request = db.transaction("tiles", "readonly").objectStore("tiles").get(key);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
async function offlineTilePut(value) {
const db = await offlineDatabase();
return new Promise((resolve, reject) => {
const request = db.transaction("tiles", "readwrite").objectStore("tiles").put(value);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
function createOfflineTileLayer() {
const layer = L.gridLayer({ attribution: "Tessere salvate offline", maxZoom: 22 });
layer.createTile = (coords, done) => {
const tile = document.createElement("img");
tile.alt = "";
tile.className = "lme-offline-image-tile";
offlineTileGet(`${coords.z}/${coords.x}/${coords.y}`).then(record => {
if (!record?.blob) {
tile.className = "lme-offline-tile";
done(null, tile);
return;
}
const url = URL.createObjectURL(record.blob);
tile.onload = () => { URL.revokeObjectURL(url); done(null, tile); };
tile.onerror = error => { URL.revokeObjectURL(url); done(error, tile); };
tile.src = url;
}).catch(error => done(error, tile));
return tile;
};
return layer;
}
function offlineSourceUrl(provider, z, x, y) {
const urls = {
osm: `https://a.tile.openstreetmap.org/${z}/${x}/${y}.png`,
satellite: `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${z}/${y}/${x}`,
topographic: `https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/${z}/${y}/${x}`,
"streets-esri": `https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/${z}/${y}/${x}`,
terrain: `https://a.tile.opentopomap.org/${z}/${x}/${y}.png`,
carto: `https://a.basemaps.cartocdn.com/rastertiles/voyager/${z}/${x}/${y}.png`
};
return urls[provider] || null;
}
function longitudeToTileX(longitude, zoom) {
return Math.floor((longitude + 180) / 360 * 2 ** zoom);
}
function latitudeToTileY(latitude, zoom) {
const value = Math.max(-85.0511, Math.min(85.0511, latitude)) * Math.PI / 180;
return Math.floor((1 - Math.log(Math.tan(value) + 1 / Math.cos(value)) / Math.PI) / 2 * 2 ** zoom);
}
async function downloadOfflineArea(extraZoomLevels = 2) {
const provider = lmeState.currentBaseName;
if (!offlineSourceUrl(provider, 0, 0, 0)) {
throw new Error("Per il download offline usa prima uno sfondo Esri, OpenStreetMap, OpenTopoMap o CARTO.");
}
const bounds = lmeState.map.getBounds();
const startZoom = Math.max(1, lmeState.map.getZoom());
const endZoom = Math.min(19, startZoom + Math.max(0, Math.min(4, Number(extraZoomLevels) || 0)));
const tiles = [];
for (let z = startZoom; z <= endZoom; z++) {
const max = 2 ** z - 1;
const x1 = Math.max(0, Math.min(max, longitudeToTileX(bounds.getWest(), z)));
const x2 = Math.max(0, Math.min(max, longitudeToTileX(bounds.getEast(), z)));
const y1 = Math.max(0, Math.min(max, latitudeToTileY(bounds.getNorth(), z)));
const y2 = Math.max(0, Math.min(max, latitudeToTileY(bounds.getSouth(), z)));
for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) {
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) tiles.push({ z, x, y });
}
}
if (tiles.length > 600) throw new Error(`Area troppo ampia (${tiles.length} tessere). Aumenta lo zoom o riduci la zona visibile.`);
let saved = 0;
for (const tile of tiles) {
const key = `${tile.z}/${tile.x}/${tile.y}`;
if (await offlineTileGet(key)) { saved++; continue; }
const response = await fetch(offlineSourceUrl(provider, tile.z, tile.x, tile.y), { mode: "cors" });
if (!response.ok) continue;
await offlineTilePut({ key, blob: await response.blob(), provider, savedAt: new Date().toISOString() });
saved++;
if (saved % 10 === 0) setInfo(`Mappe offline: ${saved}/${tiles.length} tessere salvate...`);
}
lmeState.offlineTileCount = await getOfflineTileStats();
lmeState.baseLayers.offline.redraw();
setInfo(`Area offline pronta: ${saved} tessere, zoom ${startZoom}-${endZoom}.`);
return { saved, requested: tiles.length, startZoom, endZoom };
}
async function getOfflineTileStats() {
const db = await offlineDatabase();
return new Promise((resolve, reject) => {
const request = db.transaction("tiles", "readonly").objectStore("tiles").count();
request.onsuccess = () => resolve(request.result || 0);
request.onerror = () => reject(request.error);
});
}
async function clearOfflineTiles() {
const db = await offlineDatabase();
await new Promise((resolve, reject) => {
const request = db.transaction("tiles", "readwrite").objectStore("tiles").clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
lmeState.offlineTileCount = 0;
lmeState.baseLayers.offline.redraw();
setInfo("Archivio mappe offline svuotato.");
return true;
}
function setBaseLayer(name) {
const selected = lmeState.baseLayers[name] || lmeState.baseLayers.satellite;
if (lmeState.currentBase) {
lmeState.map.removeLayer(lmeState.currentBase);
}
lmeState.currentBase = selected.addTo(lmeState.map);
lmeState.currentBaseName = selected === lmeState.baseLayers[name] ? name : "satellite";
lmeState.currentBase.bringToBack();
const labels = {
satellite: "Satellite Esri",
osm: "OpenStreetMap",
"streets-esri": "Strade dettagliate Esri",
topographic: "Topografica Esri",
terrain: "OpenTopoMap",
carto: "CARTO Voyager",
offline: "Mappe salvate offline",
"azure-road": "Strade Azure Maps",
"azure-satellite": "Satellite Azure Maps",
"google-satellite": "Satellite Google Maps",
"google-roadmap": "Strade Google Maps"
};
setInfo(`Sfondo attivo: ${labels[name] || labels.satellite}. Il catasto AdE resta sovrapposto.`);
if (lmeState.currentBaseName.startsWith("azure-")) {
void updateAzureAttribution();
} else {
clearAzureAttribution();
}
}
function configureGoogleMaps(apiKey) {
const key = (apiKey || "").trim();
if (!key) {
return Promise.reject(new Error("Chiave Google Maps mancante."));
}
if (window.google?.maps && L.gridLayer.googleMutant) {
createGoogleLayers();
return Promise.resolve();
}
if (lmeState.googleMapsLoading) {
return lmeState.googleMapsLoading;
}
lmeState.googleMapsLoading = new Promise((resolve, reject) => {
const callbackName = `lmeGoogleMapsReady${Date.now()}`;
const script = document.createElement("script");
window[callbackName] = () => {
delete window[callbackName];
createGoogleLayers();
resolve();
};
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&loading=async&callback=${callbackName}`;
script.async = true;
script.onerror = () => {
delete window[callbackName];
lmeState.googleMapsLoading = null;
reject(new Error("Impossibile caricare Google Maps. Controlla chiave, API e connessione."));
};
document.head.appendChild(script);
});
return lmeState.googleMapsLoading;
}
function createGoogleLayers() {
if (!L.gridLayer.googleMutant) {
throw new Error("Modulo Google Maps non disponibile.");
}
lmeState.baseLayers["google-satellite"] ||= L.gridLayer.googleMutant({ type: "hybrid", maxZoom: 22 });
lmeState.baseLayers["google-roadmap"] ||= L.gridLayer.googleMutant({ type: "roadmap", maxZoom: 22 });
}
function configureAzureMaps(subscriptionKey) {
const key = (subscriptionKey || "").trim();
if (!key) {
throw new Error("Chiave Azure Maps mancante.");
}
if (lmeState.azureMapsKey === key
&& lmeState.baseLayers["azure-road"]
&& lmeState.baseLayers["azure-satellite"]) {
return;
}
const activeAzureLayer = lmeState.currentBaseName.startsWith("azure-")
? lmeState.currentBaseName
: null;
if (activeAzureLayer && lmeState.currentBase) {
lmeState.map.removeLayer(lmeState.currentBase);
}
lmeState.azureMapsKey = key;
lmeState.baseLayers["azure-road"] = createAzureLayer(key, "microsoft.base.road", 22);
lmeState.baseLayers["azure-satellite"] = createAzureLayer(key, "microsoft.imagery", 19);
if (activeAzureLayer) {
lmeState.currentBase = lmeState.baseLayers[activeAzureLayer].addTo(lmeState.map);
lmeState.currentBase.bringToBack();
void updateAzureAttribution();
}
}
function clearAzureMaps() {
if (lmeState.currentBaseName.startsWith("azure-") && lmeState.currentBase) {
lmeState.map.removeLayer(lmeState.currentBase);
lmeState.currentBase = lmeState.baseLayers.satellite.addTo(lmeState.map);
lmeState.currentBase.bringToBack();
lmeState.currentBaseName = "satellite";
}
delete lmeState.baseLayers["azure-road"];
delete lmeState.baseLayers["azure-satellite"];
lmeState.azureMapsKey = null;
clearAzureAttribution();
}
function createAzureLayer(key, tilesetId, maxNativeZoom) {
const url = `https://atlas.microsoft.com/map/tile?api-version=2024-04-01&tilesetId=${tilesetId}&zoom={z}&x={x}&y={y}&tileSize=256&language=it-IT&view=Auto&subscription-key=${encodeURIComponent(key)}`;
const layer = L.tileLayer(url, {
minZoom: tilesetId === "microsoft.imagery" ? 1 : 0,
maxNativeZoom,
maxZoom: 22,
attribution: "Microsoft Azure Maps"
});
let errorShown = false;
layer.on("tileerror", () => {
if (!errorShown) {
errorShown = true;
setInfo("Azure Maps non ha restituito le tessere. Controlla quota e permessi Render della chiave.");
}
});
return layer;
}
async function updateAzureAttribution() {
if (!lmeState.azureMapsKey || !lmeState.currentBaseName.startsWith("azure-")) {
return;
}
const requestedLayer = lmeState.currentBaseName;
const tilesetId = requestedLayer === "azure-satellite" ? "microsoft.imagery" : "microsoft.base.road";
const bounds = lmeState.map.getBounds();
const zoom = Math.min(lmeState.map.getZoom(), tilesetId === "microsoft.imagery" ? 19 : 22);
const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()].join(",");
const url = `https://atlas.microsoft.com/map/attribution?api-version=2024-04-01&tilesetId=${tilesetId}&zoom=${zoom}&bounds=${bbox}`;
try {
const response = await fetch(url, {
headers: {
Accept: "application/json",
"subscription-key": lmeState.azureMapsKey
}
});
if (!response.ok) {
return;
}
const payload = await response.json();
if (lmeState.currentBaseName !== requestedLayer) {
return;
}
const attribution = [...new Set((payload.copyrights || []).map(htmlToText).filter(Boolean))].join(" | ");
setAzureAttribution(attribution);
} catch {
clearAzureAttribution();
}
}
function setAzureAttribution(text) {
clearAzureAttribution();
if (!text) {
return;
}
lmeState.azureAttribution = escapeHtml(text);
}
function clearAzureAttribution() {
lmeState.azureAttribution = null;
}
function htmlToText(value) {
const element = document.createElement("div");
element.innerHTML = String(value || "");
return element.textContent?.trim() || "";
}
function setCadastreOpacity(value) {
const opacity = Number(value);
if (!Number.isFinite(opacity)) {
return;
}
lmeState.cadastreOpacity = Math.max(0, Math.min(1, opacity));
if (lmeState.cadastre) {
lmeState.cadastre.setOpacity(lmeState.cadastreOpacity);
}
if (lmeState.cadastreZoning) {
lmeState.cadastreZoning.setOpacity(Math.max(0.8, lmeState.cadastreOpacity));
}
}
function toggleCadastreLayer(layer, enabled) {
const exists = lmeState.cadastreLayers.includes(layer);
if (enabled && !exists) {
lmeState.cadastreLayers.push(layer);
}
if (!enabled && exists) {
lmeState.cadastreLayers = lmeState.cadastreLayers.filter(item => item !== layer);
}
refreshCadastreLayer();
if (layer === "CP.CadastralZoning") {
if (enabled) {
scheduleNavigationSheetRefresh(true);
} else {
clearNavigationSheetOutline();
}
}
const layerNames = {
"CP.CadastralParcel": "Confini particelle",
codice_plla: "Numeri particelle",
"CP.CadastralZoning": "Fogli catastali",
fabbricati: "Fabbricati",
strade: "Strade catastali",
acque: "Acque"
};
if (enabled && layer === "codice_plla" && lmeState.map.getZoom() < 17) {
setInfo("Numeri particelle attivati. Ingrandisci la mappa almeno fino a zoom 17 per renderli visibili.");
return;
}
setInfo(`${layerNames[layer] || layer}: ${enabled ? "attivo" : "nascosto"}.`);
}
function scheduleNavigationSheetRefresh(immediate = false) {
window.clearTimeout(lmeState.navigationSheetRefreshTimer);
if (!lmeState.map
|| !lmeState.dotNetReference
|| !lmeState.cadastreLayers.includes("CP.CadastralZoning")) {
return;
}
if (lmeState.map.getZoom() < 15) {
clearNavigationSheetOutline();
return;
}
lmeState.navigationSheetRefreshTimer = window.setTimeout(
() => void refreshNavigationSheetOutline(),
immediate ? 0 : 650);
}
async function refreshNavigationSheetOutline() {
if (!lmeState.map
|| !lmeState.dotNetReference
|| !lmeState.cadastreLayers.includes("CP.CadastralZoning")) {
return;
}
const center = lmeState.map.getCenter();
const point = [center.lng, center.lat];
if (loadedCadastralSheetAt(point)) {
clearNavigationSheetOutline();
return;
}
if (lmeState.navigationSheetFeature?.geometry
&& geometryContainsPoint(lmeState.navigationSheetFeature.geometry, point)) {
lmeState.navigationSheetLabel?.setLatLng(center);
return;
}
const requestId = ++lmeState.navigationSheetRequestId;
try {
const xml = await lmeState.dotNetReference.invokeMethodAsync(
"GetOfficialCadastralSheetsNear",
center.lat,
center.lng);
if (requestId !== lmeState.navigationSheetRequestId
|| !lmeState.cadastreLayers.includes("CP.CadastralZoning")) {
return;
}
const candidates = gmlParcelsToGeoJson(xml).features || [];
const sheet = findContainingPolygon(point, { type: "FeatureCollection", features: candidates })
|| candidates[0];
if (!sheet) {
return;
}
const reference = String(sheet.properties?.NATIONALCADASTRALZONINGREFERENCE
|| sheet.properties?.LABEL
|| "").trim();
const previousReference = String(
lmeState.navigationSheetFeature?.properties?.NATIONALCADASTRALZONINGREFERENCE
|| lmeState.navigationSheetFeature?.properties?.LABEL
|| "").trim();
lmeState.navigationSheetFeature = sheet;
lmeState.navigationSheetLayer.clearLayers();
L.geoJSON(sheet, {
pane: "navigationSheetPane",
interactive: false,
style: {
color: "#f59e0b",
weight: 4,
opacity: 0.95,
dashArray: "10 6",
fillColor: "#fbbf24",
fillOpacity: 0.055
}
}).addTo(lmeState.navigationSheetLayer);
lmeState.navigationSheetLabel = L.marker(center, {
pane: "navigationSheetPane",
interactive: false,
icon: L.divIcon({
className: "cadastral-sheet-label",
html: escapeHtml(reference ? `Foglio ${reference}` : "Foglio catastale")
})
}).addTo(lmeState.navigationSheetLayer);
if (reference && reference !== previousReference) {
setInfo(`Confine del foglio catastale ${reference} caricato.`);
}
} catch {
if (requestId === lmeState.navigationSheetRequestId) {
setInfo("Confine del foglio catastale non disponibile dal servizio AdE. Riprovo al prossimo spostamento.");
}
}
}
function clearNavigationSheetOutline() {
window.clearTimeout(lmeState.navigationSheetRefreshTimer);
lmeState.navigationSheetRequestId++;
lmeState.navigationSheetFeature = null;
lmeState.navigationSheetLabel = null;
lmeState.navigationSheetLayer?.clearLayers();
}
function loadedCadastralSheetAt(point) {
const features = [
...(lmeState.importedSheets?.toGeoJSON?.()?.features || []),
...(lmeState.imported?.toGeoJSON?.()?.features || [])
].filter(feature => ["Polygon", "MultiPolygon"].includes(feature?.geometry?.type)
&& (feature.properties?._lmeOfficialSheet
|| feature.properties?.NATIONALCADASTRALZONINGREFERENCE
|| isSheetOrOverlayFeature(feature)));
return findContainingPolygon(point, { type: "FeatureCollection", features });
}
function refreshCadastreLayer() {
if (lmeState.cadastre) {
lmeState.map.removeLayer(lmeState.cadastre);
}
if (lmeState.cadastreZoning) {
lmeState.map.removeLayer(lmeState.cadastreZoning);
}
const zoningEnabled = lmeState.cadastreLayers.includes("CP.CadastralZoning");
const layers = activeCadastreLayers().filter(layer => layer !== "CP.CadastralZoning");
lmeState.cadastre = null;
lmeState.cadastreZoning = null;
if (layers.length) {
const cadastreLayer = L.tileLayer.wms(cadastreUrl(), {
pane: "cadastrePane",
layers: layers.join(","),
format: "image/png",
transparent: true,
version: "1.1.1",
crs: cadastreCrs,
maxZoom: 22,
opacity: lmeState.cadastreOpacity,
attribution: "Agenzia delle Entrate - CC BY 4.0"
});
attachCadastreTileRecovery(cadastreLayer);
lmeState.cadastre = cadastreLayer.addTo(lmeState.map);
}
if (zoningEnabled) {
const zoningLayer = L.tileLayer.wms(cadastreUrl(), {
pane: "cadastreZoningPane",
layers: "CP.CadastralZoning",
format: "image/png",
transparent: true,
version: "1.1.1",
crs: cadastreCrs,
minZoom: 8,
maxZoom: 14,
opacity: Math.max(0.8, lmeState.cadastreOpacity),
attribution: "Agenzia delle Entrate - CC BY 4.0"
});
attachCadastreTileRecovery(zoningLayer);
lmeState.cadastreZoning = zoningLayer.addTo(lmeState.map);
scheduleNavigationSheetRefresh(true);
}
}
function attachCadastreTileRecovery(cadastreLayer) {
let errorShown = false;
cadastreLayer.on("tileerror", event => {
const tile = event?.tile;
const retryCount = Number(tile?.dataset?.lmeRetry || 0);
if (tile && retryCount < 2) {
tile.dataset.lmeRetry = String(retryCount + 1);
window.setTimeout(() => {
const separator = tile.src.includes("?") ? "&" : "?";
tile.src = `${tile.src}${separator}_lme_retry=${Date.now()}`;
}, 450 * (retryCount + 1));
return;
}
if (errorShown) {
return;
}
errorShown = true;
setInfo("Il servizio cartografico AdE non ha risposto. La connessione internet puo essere attiva: attendi qualche secondo e riprova.");
});
cadastreLayer.on("load", () => {
errorShown = false;
});
}
function activeCadastreLayers() {
const drawOrder = [
"CP.CadastralZoning",
"strade",
"acque",
"fabbricati",
"CP.CadastralParcel",
"codice_plla"
];
return drawOrder.filter(layer => lmeState.cadastreLayers.includes(layer));
}
function setFiducialPoints(points, color, visible = true, keepPrevious = false, municipalityCode = "", sheet = "", municipalityName = "") {
if (!lmeState.fiducials) return;
const source = Array.isArray(points) ? points : [];
if (source.length || lmeState.fiducials.getLayers().length) rememberMapChange();
const normalizedMunicipality = String(municipalityCode || "").trim().toUpperCase();
const normalizedSheet = normalizeFiducialSheet(sheet);
const sheetKey = normalizedMunicipality && normalizedSheet
? `${normalizedMunicipality}:${normalizedSheet}`
: "fiducials-current";
const sheetLabel = `Foglio ${normalizedSheet || sheet}${municipalityName ? ` · ${municipalityName}` : ""}`;
if (!keepPrevious) {
managedRecordsForCategory(layerManagerCategories().find(category => category.key === "fiducials"))
.forEach(record => lmeState.managerSelectedObjectIds.delete(record.id));
lmeState.fiducials.clearLayers();
clearManagedCategoryState("fiducials");
} else {
managedRecordsForCategory(layerManagerCategories().find(category => category.key === "fiducials"))
.filter(record => record.layer?.feature?.properties?._lmeFiducialSheetKey === sheetKey)
.forEach(record => {
record.parent?.removeLayer?.(record.layer);
clearManagedObjectState(record.id);
lmeState.managerSelectedObjectIds.delete(record.id);
});
}
if (typeof color === "string" && /^#[0-9a-f]{6}$/i.test(color)) {
lmeState.fiducialColor = color;
}
source.forEach((point, index) => {
const latitude = Number(point.latitude ?? point.Latitude);
const longitude = Number(point.longitude ?? point.Longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return;
const feature = {
type: "Feature",
geometry: { type: "Point", coordinates: [longitude, latitude] },
properties: {
name: String(point.identifier ?? point.Identifier ?? `PF ${index + 1}`),
_lmeDisplayName: String(point.identifier ?? point.Identifier ?? `PF ${index + 1}`),
_lmeSource: sheetKey,
_lmeGroupName: sheetLabel,
_lmeFiducialSheetKey: sheetKey,
_lmeMunicipalityCode: normalizedMunicipality,
_lmeSheet: normalizedSheet,
_lmeColor: lmeState.fiducialColor,
_lmeFillColor: lmeState.fiducialColor,
_lmeWeight: 3,
_lmeOpacity: 1,
_lmeFillOpacity: 0.72,
cadastralNorth: point.cadastralNorth ?? point.CadastralNorth ?? null,
cadastralEast: point.cadastralEast ?? point.CadastralEast ?? null,
reliability: String(point.reliability ?? point.Reliability ?? ""),
elevation: point.elevation ?? point.Elevation ?? null,
parcel: String(point.parcel ?? point.Parcel ?? ""),
updatedAt: String(point.updatedAt ?? point.UpdatedAt ?? ""),
originId: String(point.originId ?? point.OriginId ?? ""),
monographUrl: String(point.monographUrl ?? point.MonographUrl ?? "")
}
};
const marker = createFiducialMarker(feature, L.latLng(latitude, longitude));
marker.feature = feature;
bindFiducialPopup(feature, marker);
lmeState.fiducials.addLayer(marker);
});
setFiducialPointsVisible(visible);
refreshLayerManager();
if (source.length) finishMapChange();
}
function createFiducialMarker(feature, latlng) {
const properties = feature?.properties || {};
const color = properties._lmeColor || lmeState.fiducialColor;
return L.circleMarker(latlng, {
pane: "fiducialsPane",
radius: 8,
color,
fillColor: properties._lmeFillColor || color,
weight: Number(properties._lmeWeight) || 3,
opacity: Number.isFinite(Number(properties._lmeOpacity)) ? Number(properties._lmeOpacity) : 1,
fillOpacity: Number.isFinite(Number(properties._lmeFillOpacity)) ? Number(properties._lmeFillOpacity) : 0.72
});
}
function bindFiducialPopup(feature, layer) {
const properties = feature?.properties || {};
const rows = [];
if (properties.cadastralNorth != null) rows.push(`Nord/X catastale: ${escapeHtml(properties.cadastralNorth)}`);
if (properties.cadastralEast != null) rows.push(`Est/Y catastale: ${escapeHtml(properties.cadastralEast)}`);
if (properties.reliability) rows.push(`Attendibilita: ${escapeHtml(properties.reliability)}`);
if (properties.elevation != null) rows.push(`Quota: ${escapeHtml(properties.elevation)}`);
if (properties.parcel) rows.push(`Particella: ${escapeHtml(properties.parcel)}`);
const url = String(properties.monographUrl || "");
const monograph = /^https:\/\//i.test(url)
? `<p><a href="${escapeHtml(url)}" target="_blank" rel="noopener">Apri monografia ufficiale</a></p>`
: "";
const notes = managedNotesPopupMarkup(properties);
const photo = String(properties._lmePhoto || "");
const image = photo.startsWith("data:image/") ? `<img class="lme-popup-photo" src="${photo}" alt="Foto allegata">` : "";
layer.bindPopup(`<strong>${escapeHtml(properties._lmeDisplayName || properties.name || "Punto fiduciale")}</strong><p>${rows.join("<br>")}</p>${monograph}${notes}${image}<small>Posizione cartografica sperimentale GloboGIS: verificare prima dell'uso topografico.</small>`);
applyStyleFromProperties(layer);
}
function setFiducialPointsVisible(enabled) {
lmeState.fiducialsVisible = Boolean(enabled);
if (!lmeState.map || !lmeState.fiducials) return;
if (lmeState.fiducialsVisible) {
lmeState.managerHiddenCategories.delete("fiducials");
if (!lmeState.map.hasLayer(lmeState.fiducials)) lmeState.fiducials.addTo(lmeState.map);
lmeState.fiducials.bringToFront?.();
} else {
lmeState.managerHiddenCategories.add("fiducials");
if (lmeState.map.hasLayer(lmeState.fiducials)) lmeState.map.removeLayer(lmeState.fiducials);
}
refreshLayerManager();
}
function setFiducialColor(color) {
if (!/^#[0-9a-f]{6}$/i.test(String(color))) return;
lmeState.fiducialColor = color;
const category = layerManagerCategories().find(item => item.key === "fiducials");
managedRecordsForCategory(category).forEach(record => {
const layer = record.layer;
if (layer.feature?.properties) {
layer.feature.properties._lmeColor = color;
layer.feature.properties._lmeFillColor = color;
}
applyStyleFromProperties(layer);
});
refreshLayerManager();
}
function fitFiducialPoints() {
if (!lmeState.fiducials?.getLayers().length) {
setInfo("Nessun Punto Fiduciale da inquadrare.");
return;
}
fitLayer(lmeState.fiducials);
}
function clearSelectedParcels() {
const count = managedGroupGeoJson(lmeState.selectedParcelsLayer, "selectedParcels").features.length;
if (count) rememberMapChange();
lmeState.selectedParcelsLayer.clearLayers();
clearManagedCategoryState("selectedParcels");
updateGpsInsideState();
setInfo(count ? `${count} particelle selezionate rimosse.` : "Non ci sono particelle selezionate da rimuovere.");
refreshLayerManager();
if (count) finishMapChange();
}
function setSelectedParcelsVisible(enabled) {
lmeState.selectedParcelsVisible = Boolean(enabled);
if (!lmeState.map || !lmeState.selectedParcelsLayer) {
return;
}
if (lmeState.selectedParcelsVisible) {
if (!lmeState.map.hasLayer(lmeState.selectedParcelsLayer)) {
lmeState.selectedParcelsLayer.addTo(lmeState.map);
}
lmeState.selectedParcelsLayer.bringToFront();
const count = lmeState.selectedParcelsLayer.toGeoJSON().features?.length || 0;
setInfo(count
? `${count} particelle selezionate visibili.`
: "Nessuna particella selezionata da mostrare.");
return;
}
if (lmeState.map.hasLayer(lmeState.selectedParcelsLayer)) {
lmeState.map.removeLayer(lmeState.selectedParcelsLayer);
}
setInfo("Particelle selezionate nascoste; i dati restano nel progetto.");
}
function clearMapElements() {
const count = managedGroupGeoJson(lmeState.selectedParcelsLayer, "selectedParcels").features.length
+ managedGroupGeoJson(lmeState.gpsParcelLayer, "gpsParcels").features.length
+ managedGroupGeoJson(lmeState.fiducials, "fiducials").features.length
+ managedGroupGeoJson(lmeState.imported, "imported").features.length
+ managedGroupGeoJson(lmeState.importedSheets, "importedSheets").features.length
+ managedGroupGeoJson(lmeState.drawings, "drawings").features.length
+ lmeState.gpsTrailPoints.length;
if (!count) {
setInfo("La mappa non contiene elementi da pulire.");
return;
}
if (!window.confirm("Rimuovere particelle evidenziate, Punti Fiduciali, file importati, disegni, POI e traccia GPS dalla mappa?")) {
return;
}
rememberMapChange();
resetLayerManagerProjectState();
lmeState.selectedParcelsLayer.clearLayers();
lmeState.gpsParcelLayer.clearLayers();
lmeState.fiducials.clearLayers();
lmeState.imported.clearLayers();
lmeState.importedSheets.clearLayers();
lmeState.drawings.clearLayers();
lmeState.importedMeasurements.clearLayers();
lmeState.drawingMeasurements.clearLayers();
lmeState.gpsTrailPoints = [];
lmeState.gpsTrail.setLatLngs([]);
updateGpsInsideState();
setInfo("Elementi della mappa rimossi. Sfondo e layer catastali restano attivi.");
refreshLayerManager();
finishMapChange();
}
function startGps() {
if (!navigator.geolocation) {
setInfo("Geolocalizzazione non disponibile sul dispositivo.");
setText("gpsState", "GPS non disponibile");
return;
}
if (lmeState.gpsWatchId !== null) {
setInfo("Tracking GPS gia attivo.");
return;
}
lmeState.gpsWatchId = navigator.geolocation.watchPosition(onGpsPosition, onGpsError, {
enableHighAccuracy: true,
maximumAge: 1000,
timeout: 15000
});
setText("gpsState", "Aggancio GPS in corso...");
setInfo("Tracking GPS avviato.");
}
function stopGps() {
if (lmeState.gpsWatchId !== null) {
navigator.geolocation.clearWatch(lmeState.gpsWatchId);
lmeState.gpsWatchId = null;
}
setText("gpsState", "GPS fermo");
setInfo("Tracking GPS fermato.");
}
function setNativeGpsPosition(location) {
onGpsPosition({
coords: {
latitude: location.latitude,
longitude: location.longitude,
accuracy: location.accuracy || 0,
altitude: location.altitude,
heading: location.course,
speed: location.speed
},
timestamp: location.timestamp
});
}
function setGpsPositionVisible(enabled) {
lmeState.gpsPositionVisible = Boolean(enabled);
if (!lmeState.gpsPositionVisible) {
if (lmeState.gpsMarker && lmeState.map.hasLayer(lmeState.gpsMarker)) {
lmeState.map.removeLayer(lmeState.gpsMarker);
}
if (lmeState.gpsAccuracy && lmeState.map.hasLayer(lmeState.gpsAccuracy)) {
lmeState.map.removeLayer(lmeState.gpsAccuracy);
}
setInfo("Punto e cerchio GPS nascosti. Il rilevamento continua.");
return;
}
if (lmeState.hasLiveGpsFix && lmeState.lastPosition) {
updateGpsPositionLayers(lmeState.lastPosition, lmeState.lastAccuracy);
}
setInfo("Punto e cerchio GPS visibili.");
}
function setGpsError(error) {
setText("gpsState", "GPS non disponibile");
setInfo(error || "Errore GPS.");
}
function centerOnGps() {
if (!lmeState.lastPosition) {
setInfo("Nessuna posizione GPS ancora disponibile.");
return;
}
lmeState.map.setView(lmeState.lastPosition, Math.max(lmeState.map.getZoom(), 19));
}
function toggleTrail(enabled) {
lmeState.gpsTrailEnabled = enabled;
if (enabled && !lmeState.gpsTrailStartedAt) lmeState.gpsTrailStartedAt = new Date().toISOString();
setInfo(enabled ? "Registrazione percorso avviata." : "Registrazione percorso in pausa.");
}
function clearGpsTrail() {
if (!lmeState.gpsTrailPoints.length) { setInfo("La traccia GPS e' vuota."); return false; }
if (!window.confirm("Eliminare definitivamente la traccia GPS corrente?")) return false;
rememberMapChange();
lmeState.gpsTrailPoints = [];
lmeState.gpsTrail.setLatLngs([]);
lmeState.gpsTrailStartedAt = null;
finishMapChange("Eliminazione traccia GPS");
setInfo("Traccia GPS eliminata.");
return true;
}
function getGpsTrailStats() {
const length = lmeState.gpsTrailPoints.reduce((sum, point, index) => index ? sum + lmeState.map.distance(lmeState.gpsTrailPoints[index - 1], point) : 0, 0);
const durationSeconds = lmeState.gpsTrailStartedAt ? Math.max(0, Math.round((Date.now() - new Date(lmeState.gpsTrailStartedAt).getTime()) / 1000)) : 0;
return { points: lmeState.gpsTrailPoints.length, length, durationSeconds, recording: lmeState.gpsTrailEnabled };
}
function onGpsPosition(position) {
const { latitude, longitude, accuracy } = position.coords;
const latlng = [latitude, longitude];
lmeState.lastPosition = latlng;
lmeState.lastAccuracy = Math.max(0, Number(accuracy) || 0);
const firstLiveFix = !lmeState.hasLiveGpsFix;
lmeState.hasLiveGpsFix = true;
updateGpsPositionLayers(latlng, lmeState.lastAccuracy);
if (firstLiveFix) {
lmeState.map.setView(latlng, 19);
} else if (!lmeState.map.getBounds().contains(latlng)) {
lmeState.map.panTo(latlng, {
animate: true,
duration: 0.5,
easeLinearity: 0.25
});
}
if (lmeState.gpsTrailEnabled) {
lmeState.gpsTrailPoints.push(latlng);
lmeState.gpsTrail.setLatLngs(lmeState.gpsTrailPoints);
if (lmeState.gpsTrailPoints.length % 10 === 0) finishMapChange();
}
setText("gpsState", `${latitude.toFixed(6)}, ${longitude.toFixed(6)} - precisione ${Math.round(lmeState.lastAccuracy)} m`);
updateGpsInsideState();
}
function updateGpsPositionLayers(latlng, accuracy) {
let created = false;
if (!lmeState.gpsMarker) {
lmeState.gpsMarker = L.marker(latlng, { pane: "gpsPositionPane", title: "La tua posizione" });
lmeState.gpsMarker.lmeManagerId = "gps-position-marker";
lmeState.gpsMarker.lmeManagerLabel = "Posizione GPS";
lmeState.gpsAccuracy = L.circle(latlng, {
pane: "gpsAccuracyPane",
radius: accuracy,
color: "#2563eb",
weight: 2,
fillColor: "#60a5fa",
fillOpacity: 0.16
});
lmeState.gpsAccuracy.lmeManagerId = "gps-accuracy-circle";
lmeState.gpsAccuracy.lmeManagerLabel = "Cerchio precisione GPS";
created = true;
} else {
lmeState.gpsMarker.setLatLng(latlng);
lmeState.gpsAccuracy.setLatLng(latlng).setRadius(accuracy);
}
const categoryVisible = lmeState.gpsPositionVisible
&& !lmeState.managerHiddenCategories.has("gpsPosition");
if (categoryVisible
&& !lmeState.managerHiddenObjectIds.has("gps-position-marker")
&& !lmeState.map.hasLayer(lmeState.gpsMarker)) {
lmeState.gpsMarker.addTo(lmeState.map);
} else if ((!categoryVisible || lmeState.managerHiddenObjectIds.has("gps-position-marker"))
&& lmeState.map.hasLayer(lmeState.gpsMarker)) {
lmeState.map.removeLayer(lmeState.gpsMarker);
}
if (categoryVisible
&& !lmeState.managerHiddenObjectIds.has("gps-accuracy-circle")
&& !lmeState.map.hasLayer(lmeState.gpsAccuracy)) {
lmeState.gpsAccuracy.addTo(lmeState.map);
} else if ((!categoryVisible || lmeState.managerHiddenObjectIds.has("gps-accuracy-circle"))
&& lmeState.map.hasLayer(lmeState.gpsAccuracy)) {
lmeState.map.removeLayer(lmeState.gpsAccuracy);
}
if (created) {
refreshLayerManager();
}
}
function onGpsError(error) {
setText("gpsState", "GPS non autorizzato o non disponibile");
setInfo(error.message || "Errore GPS.");
}
function updateGpsInsideState() {
if (!lmeState.lastPosition) {
return;
}
const point = [lmeState.lastPosition[1], lmeState.lastPosition[0]];
updateGpsProximity(point);
const officialMatch = findContainingPolygon(point, lmeState.selectedParcelsLayer.toGeoJSON())
|| findContainingPolygon(point, lmeState.gpsParcelLayer.toGeoJSON());
if (officialMatch) {
const reference = officialMatch.properties?.NATIONALCADASTRALREFERENCE
|| officialMatch.properties?.INSPIREID_LOCALID
|| officialMatch.properties?.LABEL
|| "particella AdE";
setText("parcelState", `Dentro particella AdE: ${reference}`);
return;
}
const match = findContainingPolygon(point, lmeState.imported.toGeoJSON()) || findContainingPolygon(point, lmeState.drawings.toGeoJSON());
if (match) {
const name = match.properties?.name || match.properties?.Name || "area caricata/disegnata";
setText("parcelState", `Dentro: ${name}`);
return;
}
setText(
"parcelState",
lmeState.selectedParcelsLayer.getLayers().length || lmeState.gpsParcelLayer.getLayers().length
? "Fuori dalla particella catastale evidenziata"
: "Fuori dai poligoni caricati");
}
function updateGpsProximity(coordinates) {
if (!lmeState.gpsProximityEnabled || !window.turf) return;
const record = lmeState.managerRecords?.get(lmeState.managerHighlightedObjectId)
|| [...lmeState.managerSelectedObjectIds].map(id => lmeState.managerRecords?.get(id)).find(Boolean);
if (!record?.layer) return;
try {
const feature = record.layer.toGeoJSON();
const boundary = feature.geometry.type.includes("Polygon") ? turf.polygonToLine(feature) : feature;
const lines = boundary.type === "FeatureCollection" ? boundary.features : [boundary];
let nearest = null;
lines.forEach(line => {
const candidate = line.geometry.type.includes("Line") ? turf.nearestPointOnLine(line, turf.point(coordinates), { units: "meters" }) : null;
if (candidate && (!nearest || candidate.properties.dist < nearest.properties.dist)) nearest = candidate;
});
if (!nearest) return;
const distance = nearest.properties.dist;
const bearing = turf.bearing(turf.point(coordinates), nearest);
const direction = bearingToCardinal(bearing);
const inside = feature.geometry.type.includes("Polygon") && turf.booleanPointInPolygon(turf.point(coordinates), feature);
setText("gpsState", `${coordinates[1].toFixed(6)}, ${coordinates[0].toFixed(6)} - bordo ${formatDistance(distance)} ${direction}`);
if (lmeState.gpsBoundaryAlertEnabled && lmeState.lastBoundaryState !== null && inside !== lmeState.lastBoundaryState) {
navigator.vibrate?.([180, 100, 180]);
setInfo(inside ? "Ingresso nell'area selezionata." : "Uscita dall'area selezionata.");
}
lmeState.lastBoundaryState = inside;
} catch { }
}
function bearingToCardinal(value) {
const labels = ["N", "NE", "E", "SE", "S", "SO", "O", "NO"];
return labels[Math.round(((value % 360) + 360) % 360 / 45) % 8];
}
function saveGpsMeasurementPoint() {
if (!lmeState.lastPosition) { setInfo("Attendi una posizione GPS valida."); return; }
if (!canAddDrawingObjects()) return;
rememberMapChange();
const now = new Date();
addDrawingFeature({ type: "Feature", properties: {
name: `Rilievo GPS ${now.toLocaleString("it-IT")}`,
_lmeAccuracy: lmeState.lastAccuracy,
_lmeMeasuredAt: now.toISOString(),
_lmeNotes: `Precisione dichiarata: ${Math.round(lmeState.lastAccuracy)} m`
}, geometry: { type: "Point", coordinates: [lmeState.lastPosition[1], lmeState.lastPosition[0]] } }, true);
refreshLayerManager(); finishMapChange(); setInfo("Punto GPS salvato con precisione, data e ora.");
}
function gmlParcelsToGeoJson(xmlText) {
const xml = new DOMParser().parseFromString(xmlText, "text/xml");
const members = Array.from(xml.getElementsByTagNameNS("*", "member"));
const features = members.map(member => parcelMemberToFeature(member)).filter(Boolean);
return { type: "FeatureCollection", features };
}
function parcelMemberToFeature(member) {
const posList = member.getElementsByTagNameNS("*", "posList")[0]?.textContent?.trim();
if (!posList) {
return null;
}
const values = posList.split(/\s+/).map(Number).filter(value => !Number.isNaN(value));
const ring = [];
for (let index = 0; index < values.length; index += 2) {
ring.push([values[index + 1], values[index]]);
}
const properties = {};
[
"INSPIREID_LOCALID",
"INSPIREID_NAMESPACE",
"LABEL",
"NATIONALCADASTRALREFERENCE",
"NATIONALCADASTRALZONINGREFERENCE",
"LEVEL",
"LEVELNAME",
"ADMINISTRATIVEUNIT"
].forEach(name => {
const value = member.getElementsByTagNameNS("*", name)[0]?.textContent;
if (value) {
properties[name] = value;
}
});
return {
type: "Feature",
properties,
geometry: {
type: "Polygon",
coordinates: [ring]
}
};
}
function findContainingPolygon(point, geojson) {
const features = geojson?.features || [];
return features.find(feature => feature.geometry && geometryContainsPoint(feature.geometry, point));
}
function geometryContainsPoint(geometry, point) {
if (geometry.type === "Polygon") {
return polygonContainsPoint(geometry.coordinates, point);
}
if (geometry.type === "MultiPolygon") {
return geometry.coordinates.some(polygon => polygonContainsPoint(polygon, point));
}
if (geometry.type === "GeometryCollection") {
return geometry.geometries.some(item => geometryContainsPoint(item, point));
}
return false;
}
function polygonContainsPoint(rings, point) {
if (!rings.length || !ringContainsPoint(rings[0], point)) {
return false;
}
return !rings.slice(1).some(ring => ringContainsPoint(ring, point));
}
function ringContainsPoint(ring, point) {
const [x, y] = point;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0], yi = ring[i][1];
const xj = ring[j][0], yj = ring[j][1];
const intersect = ((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi);
if (intersect) {
inside = !inside;
}
}
return inside;
}
async function queryFeatureInfo(latlng) {
if (!lmeState.cadastreLayers.includes("CP.CadastralParcel")) {
return "";
}
const point = lmeState.map.latLngToContainerPoint(latlng, lmeState.map.getZoom());
const size = lmeState.map.getSize();
const bbox = lmeState.map.getBounds();
const wmsBbox = `${bbox.getSouth()},${bbox.getWest()},${bbox.getNorth()},${bbox.getEast()}`;
const url = `${cadastreUrl()}service=WMS&version=1.3.0&request=GetFeatureInfo&layers=CP.CadastralParcel&query_layers=CP.CadastralParcel&styles=&crs=EPSG:4258&bbox=${wmsBbox}&width=${size.x}&height=${size.y}&i=${Math.round(point.x)}&j=${Math.round(point.y)}&info_format=text/html`;
try {
const response = await fetch(url);
const text = await response.text();
return text.includes("<body") ? text : escapeHtml(text);
} catch {
return "";
}
}
function fitLayer(layer) {
try {
const bounds = layer.getBounds();
if (bounds.isValid()) {
lmeState.map.fitBounds(bounds.pad(0.15));
}
} catch {
setInfo("Nessuna geometria da inquadrare.");
}
}
function cadastreUrl() {
return "https://wms.cartografia.agenziaentrate.gov.it/inspire/wms/ows01.php?language=ita&";
}
function setInfo(text) {
const container = document.getElementById("mapInfo");
if (!container) return;
setText("mapInfoText", text);
container.hidden = false;
if (lmeState.infoTimer) {
window.clearTimeout(lmeState.infoTimer);
lmeState.infoTimer = null;
}
if (lmeState.infoTimeoutSeconds > 0) {
lmeState.infoTimer = window.setTimeout(hideInfo, lmeState.infoTimeoutSeconds * 1000);
}
}
function hideInfo() {
const container = document.getElementById("mapInfo");
if (container) container.hidden = true;
if (lmeState.infoTimer) {
window.clearTimeout(lmeState.infoTimer);
lmeState.infoTimer = null;
}
}
function setInfoTimeout(seconds) {
const value = Number(seconds);
lmeState.infoTimeoutSeconds = Number.isFinite(value) ? Math.min(30, Math.max(0, value)) : 3;
const container = document.getElementById("mapInfo");
if (container && !container.hidden) {
setInfo(document.getElementById("mapInfoText")?.textContent || "");
}
}
function setText(id, text) {
const element = document.getElementById(id);
if (element) {
element.textContent = text;
}
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}