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, 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, 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, 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 init(dotNetReference) { if (dotNetReference) { lmeState.dotNetReference = dotNetReference; } if (lmeState.map) { return; } 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: "© 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 © 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 © 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 © Esri" }); lmeState.baseLayers.terrain = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", { maxNativeZoom: 17, maxZoom: 22, attribution: "Map data © OpenStreetMap contributors, SRTM | Map style © 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: "© OpenStreetMap contributors © 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 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(); 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.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; } 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 = ``; 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) { 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 { 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; lmeState.compassHeading = ((parsed % 360) + 360) % 360; if (lmeState.compassVisible && lmeState.compassMode === "heading") { lmeState.map?.setHeading?.(lmeState.compassHeading, { ease: 0.2, deadzone: 1 }); } updateCompassDisplay(); } function updateCompassDisplay() { const element = lmeState.compassElement; if (!element) return; const rotation = -lmeState.compassHeading; element.classList.toggle("is-fixed", lmeState.compassMode === "fixed"); element.style.setProperty("--compass-rotation", `${rotation}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) { 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 || "#ef4444", _lmeFillColor: feature.properties?._lmeFillColor || "#fbbf24", _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) : 0.26 }; 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(`${escapeHtml(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 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 `${renameButton}${moveButton}${removeButton}`; } 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.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 => ``).join(""); const dialog = showAppDialog("Sposta nel gruppo", `

Cambia l'appartenenza di ${escapeHtml(subject)} senza modificarne la posizione sulla mappa.

`); 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 => ``).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; }); } } 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) ? `` : ""; const removeButton = managedCategoryCanRemove(category, records) ? `` : ""; const moveButton = records.length && managedCategoryCanMove(category.key) ? `` : ""; 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 = `${layerExpandButton(group.open, categoryName)}${escapeHtml(categoryName)}${rowActions}${layerManagerMoveButtons("gruppo")}${records.length}
`; 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( ``, managedCategoryCanMove(category.key) ? `` : "", managedObjectCanRemove(records[0]) ? `` : ""); block.classList.add("layer-manager-with-actions"); const blockLocked = records.length > 0 && records.every(record => Boolean(record.layer?.feature?.properties?._lmeLocked)); block.innerHTML = `${layerExpandButton(block.open, blockName)}${escapeHtml(blockName)}${rowActions}${layerManagerMoveButtons("blocco")}${records.length}
`; 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) { 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) ? `` : ""; const removeButton = managedObjectCanRemove(record) ? `` : ""; const moveButton = managedCategoryCanMove(record.categoryKey) ? `` : ""; 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 = `${layerExpandButton(object.open, objectName)}${escapeHtml(objectName)}${rowActions}${layerManagerMoveButtons("oggetto")}
`; 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 => `${escapeHtml(value)}`).join("") : "Nessuna misura disponibile"; 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 = `
${selectedCount} selezionati
`; 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") { lmeState.managerSelectedObjectIds.clear(); refreshLayerManager(); } 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); tools.innerHTML = `Azioni gruppo${escapeHtml(managedGroupTotals(records))}
${managedColorEditorMarkup("color", "Colore linea", lineColor)} ${managedColorEditorMarkup("fillColor", "Colore riempimento", fillColor)}
`; const applyCurrentStyle = () => { applyManagedStyle(records, { color: tools.querySelector("[data-color-picker='color']").value, fillColor: tools.querySelector("[data-color-picker='fillColor']").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", () => { records.forEach(record => lmeState.managerSelectedObjectIds.add(record.id)); refreshLayerManager(); }); 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 ? `
Note${notes.map(note => `

${escapeHtml(note.text).replaceAll("\n", "
")}

`).join("")}
` : ""; } 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) => `
Nota ${index + 1}

${escapeHtml(note.text).replaceAll("\n", "
")}

${canEdit ? `
` : ""}
`).join(""); const editor = canEdit ? `
` : `

${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."}

`; const dialog = showAppDialog(title, `
${list || "

Nessuna nota presente.

"}
${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); tools.innerHTML = `
${managedColorEditorMarkup("color", "Colore linea", lineColor)} ${managedColorEditorMarkup("fillColor", "Colore riempimento", fillColor)}
Le note multiple sono modificabili con LME Pro.
${properties._lmePhoto ? `Foto allegata` : ""}
`; tools.querySelector("[data-field='selected']").addEventListener("change", event => { if (event.target.checked) lmeState.managerSelectedObjectIds.add(record.id); else lmeState.managerSelectedObjectIds.delete(record.id); refreshLayerManager(); }); 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, 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 `
${escapeHtml(label)}
`; } 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) => ``).join(""); const dialog = showAppDialog("Metadati oggetto", `

Ogni metadato viene salvato nel progetto e negli export GeoJSON, KML e KMZ.

Copia da un altro oggettoDisattiva il flag per importare soltanto i nomi dei metadati con valore vuoto.
`); const rows = dialog.querySelector(".attribute-editor-rows"); const addRow = (key = "", value = "") => { const row = document.createElement("div"); row.className = "attribute-editor-row"; row.innerHTML = ``; 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, `
WGS84${point.lat.toFixed(7)}, ${point.lng.toFixed(7)}UTM zona ${utm.zone}${utm.band}E ${utm.easting.toFixed(2)} m
N ${utm.northing.toFixed(2)} m
`); 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 = `
${escapeHtml(title)}
${bodyHtml}
`; 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" ? `` : ``; return ``; }).join(""); const dialog = showAppDialog(title, `
${fieldHtml}
`); 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 `${escapeHtml(managedObjectLabel(record, index))}${escapeHtml(managedCategoryName(layerManagerCategories().find(item => item.key === record.categoryKey) || { key: record.categoryKey, label: record.categoryKey }, [record]))}${escapeHtml(properties.foglio || properties.FOGLIO || "")}${escapeHtml(properties.particella || properties.PARTICELLA || properties.LABEL || "")}${escapeHtml(details.slice(0, 2).join(" | "))}`; }).join(""); const dialog = showAppDialog("Elenco oggetti e particelle", `
Seleziona piรน oggetti con i flag nella colonna Selez.Il lucchetto impedisce modifiche e rimozioni; la matita modifica i metadati.Note apre l'elenco completo; le modifiche richiedono LME Pro.
${body || ""}
Selez.BloccaMappaMetadatiNoteNomeGruppoFoglioParticellaMisure
Nessun oggetto
`); 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.querySelectorAll("[data-select]").forEach(input => input.addEventListener("change", () => { if (input.checked) lmeState.managerSelectedObjectIds.add(input.dataset.select); else lmeState.managerSelectedObjectIds.delete(input.dataset.select); const record = byId.get(input.dataset.select); if (record && input.checked) { lmeState.managerHighlightedObjectId = record.id; lmeState.managerHighlightedCategoryKey = record.categoryKey; renderManagerHighlight([record]); } refreshLayerManager(); 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(); highlightManagedObject(record); })); 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 => `${escapeHtml(new Date(item.at).toLocaleString("it-IT"))}${escapeHtml(item.action || "Modifica mappa")}`).join(""); showAppDialog("Registro operazioni", `
${rows || ""}
Data e oraOperazione
Nessuna operazione registrata
`); return lmeState.auditLog.length; } function updateManagedProperties(records, change) { if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; } 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.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") { 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: `${escapeHtml(managedObjectLabel(record, index))}`, 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 { 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 addResult = (feature, name, color) => { if (!feature) return; feature.properties = { name, _lmeColor: color, _lmeFillColor: color, _lmeFillOpacity: 0.38, _lmeManagerId: undefined }; addDrawingFeature(feature); }; addResult(onlyFirst, `Solo ${managedObjectLabel(polygons[0], 0)}`, "#ef4444"); addResult(onlySecond, `Solo ${managedObjectLabel(polygons[1], 1)}`, "#3b82f6"); addResult(overlap, "Sovrapposizione", "#16a34a"); 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)}.`; finishManagedGeometryChange(message); 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 showGeometryResult(title, message, tone = "success") { setInfo(`${title}: ${message}`); const icons = { success: "โœ“", warning: "!", error: "ร—" }; showAppDialog(title, `

${escapeHtml(message)}

`); } function showLeafletToolbarHelp() { showAppDialog("Guida strumenti mappa", `

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.

+
IngrandisciAumenta lo zoom mantenendo il centro attuale. Esempio: avvicinati a un vertice per posizionarlo con maggiore precisione.
โˆ’
RiduciAllarga l'area visibile. Esempio: usalo per controllare insieme particelle appartenenti a fogli vicini.
โ—
Punto di interesseTocca la posizione da memorizzare. Esempio: segna un cancello, un cippo o il punto di accesso al fondo.
๏ผ
LineaTocca 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.
โฌ 
PoligonoTocca almeno tre vertici e chiudi la figura. Esempio: delimita un'area di intervento; l'app calcola area e perimetro.
โ–ก
RettangoloTrascina tra due angoli opposti. Esempio: crea rapidamente l'ingombro rettangolare di un fabbricato.
โ—‹
CerchioTocca il centro e trascina fino al raggio desiderato. Esempio: rappresenta una fascia di rispetto attorno a un pozzo.
โœŽ
Modifica disegniPremi la matita e scegli Solo selezionato oppure Tutti. Sposta i vertici e conferma con Salva; gli oggetti bloccati devono prima essere sbloccati.
โ–ฑ
Elimina disegniPremi il cestino e scegli Solo selezionato oppure Tutti. L'oggetto rimosso puรฒ essere recuperato con Annulla o dal cestino negli strumenti avanzati.
โœ“
Controlla e riparaVerifica gli oggetti selezionati e prova a correggere coordinate duplicate o poligoni non validi. Esempio: usalo quando un contorno importato non si esporta o si riempie male; al termine compare un riepilogo.
โ—‰
Crea foroSeleziona esattamente due poligoni: prima l'area esterna, poi quella interna. Esempio: sottrai il cortile dal perimetro di un fabbricato o un'isola esclusa da un'area di lavoro.
โ‡„
Confronta due confiniSeleziona esattamente due poligoni. L'app crea il solo primo in rosso, il solo secondo in blu e la sovrapposizione in verde, mostrando le rispettive aree.
PF
Scarica Punti FiducialiSeleziona una particella o un foglio catastale; in alternativa usa la particella rilevata dal GPS. I PF vengono aggiunti come sottogruppo del relativo foglio e richiedono LME Pro.
?
GuidaRiapre questa descrizione. I risultati di riparazione e confronto vengono mostrati anche in una finestra; per uscire dal confronto usa Annulla oppure avvia un nuovo confronto.
`); } 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: "โ‡„", title: "Confronta due confini", run: compareSelectedGeometries }, { 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; } L.DomEvent.on(button, "click", event => { L.DomEvent.stop(event); 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 = `${escapeHtml(label)}`; 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 ``; } 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 (lmeState.managerHighlightedObjectId === record.id) { clearManagerHighlight(); return; } clearManagerHighlight(); lmeState.managerHighlightedObjectId = record.id; lmeState.managerHighlightedCategoryKey = record.categoryKey; document.querySelectorAll(".layer-manager-object").forEach(row => { row.classList.toggle("layer-manager-object-selected", row.dataset.objectId === record.id); }); setSelectedDrawing(record.categoryKey === "drawings" ? record.layer : null); renderManagerHighlight([record]); if (lmeState.autoFitSelectedObject) { fitLayer(record.layer); } revealManagedRecordInLayerManager(record.id); } 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()) { return; } const record = { id: managedLayerId(layer, "drawings"), categoryKey: "drawings", layer, parent: lmeState.drawings, source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente") }; clearManagerHighlight(); lmeState.managerHighlightedObjectId = record.id; lmeState.managerHighlightedCategoryKey = "drawings"; document.querySelectorAll(".layer-manager-object").forEach(row => { row.classList.toggle("layer-manager-object-selected", row.dataset.objectId === record.id); }); setSelectedDrawing(layer); renderManagerHighlight([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.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 (lmeState.managerHighlightedParentRecords?.length) { renderManagerHighlight(lmeState.managerHighlightedParentRecords); return; } const selected = lmeState.managerRecords.get(lmeState.managerHighlightedObjectId); renderManagerHighlight(selected ? [selected] : []); } function renderManagerHighlight(records) { lmeState.managerHighlight?.clearLayers(); const features = records.flatMap(record => { const geojson = record.layer?.toGeoJSON?.(); if (!geojson) { return []; } return geojson.type === "FeatureCollection" ? geojson.features : [geojson]; }); if (!features.length) { return; } L.geoJSON({ type: "FeatureCollection", features }, { pane: "managerHighlightPane", interactive: false, style: { color: "#ff00d4", weight: 8, opacity: 1, fillColor: "#00fff0", fillOpacity: 0.16 }, pointToLayer: (feature, latlng) => L.circleMarker(latlng, { pane: "managerHighlightPane", radius: 12, color: "#ff00d4", weight: 5, fillColor: "#00fff0", fillOpacity: 0.9, interactive: false }) }).addTo(lmeState.managerHighlight); } function clearManagerHighlight() { 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 ``; } 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(`${escapeHtml(label)}
${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 = `
  • Solo selezionato
  • Tutti
  • `; 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.options.pane = "drawingsPane"; if (layer instanceof L.Path) { layer.options.renderer = lmeState.drawingsRenderer; } layer.feature = layer.feature || { type: "Feature", properties: {} }; layer.feature.properties = layer.feature.properties || {}; 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()) { return; } lmeState.ignoreMapClickUntil = Date.now() + 1000; if (event.originalEvent) { event.originalEvent._lmeDrawingSelected = true; L.DomEvent.stopPropagation(event.originalEvent); } const id = managedLayerId(layer, "drawings"); if (event.originalEvent?.ctrlKey || event.originalEvent?.metaKey || event.originalEvent?.shiftKey) { if (lmeState.managerSelectedObjectIds.has(id)) lmeState.managerSelectedObjectIds.delete(id); else lmeState.managerSelectedObjectIds.add(id); } else { lmeState.managerSelectedObjectIds.clear(); lmeState.managerSelectedObjectIds.add(id); } selectDrawingLayer(layer); refreshLayerManager(); window.requestAnimationFrame(() => revealManagedRecordInLayerManager(id)); }); } function bindManagedMapSelection(layer, categoryKey, parent) { if (layer.lmeManagedMapSelectionBound) return; layer.lmeManagedMapSelectionBound = true; layer.on("click", event => { if (Date.now() < lmeState.ignoreMapClickUntil) 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") }; const multiple = event.originalEvent?.ctrlKey || event.originalEvent?.metaKey || event.originalEvent?.shiftKey; if (!multiple) lmeState.managerSelectedObjectIds.clear(); if (multiple && lmeState.managerSelectedObjectIds.has(record.id)) lmeState.managerSelectedObjectIds.delete(record.id); else lmeState.managerSelectedObjectIds.add(record.id); highlightManagedObject(record); refreshLayerManager(); window.requestAnimationFrame(() => revealManagedRecordInLayerManager(record.id)); setInfo(`Selezionato dalla mappa: ${managedObjectEditableName(record) || "oggetto"}.`); }); } 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)}
    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))}
    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: `${html}`, 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(); 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(`${escapeHtml(reference)}`); applyStyleFromProperties(layer); } }).addTo(lmeState.selectedParcelsLayer); 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) { 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 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/") ? `Foto allegata` : ""; const noteList = managedNotesPopupMarkup(properties); layer.bindPopup(`${label ? `${escapeHtml(label)}` : ""}${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) ? `

    Apri monografia ufficiale

    ` : ""; const notes = managedNotesPopupMarkup(properties); const photo = String(properties._lmePhoto || ""); const image = photo.startsWith("data:image/") ? `Foto allegata` : ""; layer.bindPopup(`${escapeHtml(properties._lmeDisplayName || properties.name || "Punto fiduciale")}

    ${rows.join("
    ")}

    ${monograph}${notes}${image}Posizione cartografica sperimentale GloboGIS: verificare prima dell'uso topografico.`); 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(" 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("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); }