const lmeState = { map: null, baseLayers: {}, currentBase: null, currentBaseName: "satellite", layerOrder: [ "gpsPosition", "drawings", "gpsTrail", "gpsParcels", "selectedParcels", "importedSheets", "imported", "cadastre" ], cadastre: null, cadastreLayers: ["CP.CadastralParcel", "codice_plla", "CP.CadastralZoning", "fabbricati"], cadastreOpacity: 0.75, imported: null, importedVisible: true, importedSheets: null, importedSheetsVisible: false, importedMeasurements: null, drawings: null, drawingMeasurements: null, drawControl: null, 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, gpsParcelLayer: null, gpsTrailEnabled: false, gpsTrailPoints: [], gpsWatchId: null, lastPosition: null, fileImportBound: false, resizeBound: false, googleMapsLoading: null, azureMapsKey: null, azureAttribution: null, managerHighlight: null, managerHighlightedObjectId: null, managerHighlightedCategoryKey: null, managerHiddenObjects: new Map(), managerHiddenObjectIds: new Set(), managerHiddenCategories: new Set(), managerObjectOrder: {} }; // 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, clearMapElements, fitImported, clearImported, setIncludeKmlSheetLayer, setImportedLayerVisible, setImportedSheetLayerVisible, setLayerOrder, refreshLayerManager, setImportedMeasurementsVisible, setDrawingMeasurementsVisible, addProjectMarker, startMeasurementDrawing, setDrawToolsVisible, clearDrawings, createMapExportGeoJson, createProjectSnapshot, loadProjectSnapshot, getLegacyProjectSnapshot, clearLegacyProjectSnapshot, setBaseLayer, configureGoogleMaps, configureAzureMaps, clearAzureMaps, setCadastreOpacity, toggleCadastreLayer, startGps, stopGps, centerOnGps, toggleTrail, setNativeGpsPosition, setGpsPositionVisible, setGpsError, resize, showInfo: setInfo }; function init() { if (lmeState.map) { return; } lmeState.map = L.map("map", { zoomControl: 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.currentBase = lmeState.baseLayers.satellite.addTo(lmeState.map); restoreLayerOrder(); Object.values(managedLayerPanes()).forEach(paneName => { if (!lmeState.map.getPane(paneName)) { lmeState.map.createPane(paneName); } }); const managerHighlightPane = lmeState.map.createPane("managerHighlightPane"); managerHighlightPane.style.zIndex = "650"; setLayerOrder(lmeState.layerOrder); lmeState.imported = L.featureGroup().addTo(lmeState.map); lmeState.importedSheets = L.featureGroup(); lmeState.drawings = L.featureGroup().addTo(lmeState.map); lmeState.importedMeasurements = L.layerGroup().addTo(lmeState.map); lmeState.drawingMeasurements = L.layerGroup().addTo(lmeState.map); lmeState.selectedParcelsLayer = L.featureGroup().addTo(lmeState.map); lmeState.gpsParcelLayer = L.featureGroup().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); refreshCadastreLayer(); bindFileImport(); bindDrawingEvents(); if (!lmeState.resizeBound) { window.addEventListener("resize", resize); window.addEventListener("orientationchange", resize); lmeState.resizeBound = true; } lmeState.map.on("click", async event => { 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(); }); 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(); } }); setInfo("Mappa pronta: satellite + catasto AdE"); } 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; } 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; } const ref = feature.properties?.NATIONALCADASTRALREFERENCE || feature.properties?.INSPIREID_LOCALID || label; const targetGroup = normalizedParcel ? lmeState.selectedParcelsLayer : lmeState.gpsParcelLayer; if (!normalizedParcel) { targetGroup.clearLayers(); } const existingLayer = normalizedParcel ? findParcelLayerByReference(targetGroup, ref) : null; if (existingLayer) { if (shouldFit) { fitLayer(existingLayer); } setText("parcelState", `Particella selezionata: ${ref}`); setInfo(`La particella ${ref} e' gia evidenziata sulla mappa.`); return; } 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 (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}`); } refreshLayerManager(); } catch (error) { setInfo(`Geometria catastale non leggibile: ${error.message}`); } } function normalizeParcel(parcel) { return String(parcel || "").trim().toUpperCase(); } function findParcelLayerByReference(group, reference) { let match = null; group.eachLayer(layer => { if (match) { return; } if (layer.lmeParcelReference === reference) { match = layer; return; } if (typeof layer.eachLayer === "function") { match = findParcelLayerByReference(layer, reference); } }); return match; } 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 { const result = await readGisFile(file); 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" : ""; setInfo(`Caricato ${file.name}: ${featureCount} particelle/elementi${includedSheets}${ignored}${reprojected}.`); updateGpsInsideState(); refreshLayerManager(); } catch (error) { setInfo(`File non leggibile: ${error.message}`); } finally { event.target.value = ""; } }); lmeState.fileImportBound = true; } 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)})`); } 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 }; } 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"); } 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 optionalFeatures = [...sheetParts, ...nonPolygonFeatures]; if (!parcels.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: parcels }, sheetGeojson: { type: "FeatureCollection", features: lmeState.includeKmlSheetLayer ? optionalFeatures : [] }, ignoredCount: lmeState.includeKmlSheetLayer ? 0 : optionalFeatures.length, reprojected: false }; } 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") { node.properties = { ...(node.properties || {}), _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", importedSheets: "importedSheetPane", imported: "importedPane", 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 pane = lmeState.map.getPane(panes[key]); if (pane) { pane.style.zIndex = String(590 - index * 20); } }); try { localStorage.setItem("lmeMapLayerOrder", JSON.stringify(normalized)); } catch { } } function restoreLayerOrder() { try { const saved = JSON.parse(localStorage.getItem("lmeMapLayerOrder") || "null"); if (Array.isArray(saved)) { lmeState.layerOrder = saved; } } 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) }, { 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: "importedSheets", label: "Cornici e fogli importati", roots: [lmeState.importedSheets].filter(Boolean), blocks: true }, { key: "imported", label: "Dati importati", roots: [lmeState.imported].filter(Boolean), blocks: 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) { 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.NATIONALCADASTRALREFERENCE || properties.INSPIREID_LOCALID || properties.LABEL || properties.name || properties.Name; 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 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() { const container = document.getElementById("layerManagerTree"); if (!container || !lmeState.map) { return; } applyStoredLayerManagerVisibility(); container.innerHTML = ""; lmeState.managerRecords = new Map(); 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); 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)); container.appendChild(renderLayerManagerCategory(category, records)); }); applyManagedVisualOrder(container); bindLayerManagerDrag(container); bindLayerManagerMoveButtons(container); } function renderLayerManagerCategory(category, records) { const group = document.createElement("details"); group.className = "layer-manager-group"; group.dataset.dragType = "category"; group.dataset.categoryKey = category.key; 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; group.innerHTML = `${escapeHtml(category.label)}${layerManagerMoveButtons("gruppo")}${records.length}
`; 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)); const children = group.querySelector(".layer-manager-children"); 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.dataset.dragType = "block"; block.dataset.categoryKey = category.key; block.dataset.blockKey = source; const visibleCount = records.filter(record => !lmeState.managerHiddenObjectIds.has(record.id)).length; const visible = visibleCount === records.length; block.innerHTML = `${escapeHtml(source)}${layerManagerMoveButtons("blocco")}${records.length}
`; 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(); }); const children = block.querySelector(".layer-manager-children"); 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"; object.dataset.dragType = "object"; object.dataset.objectId = record.id; const visible = !lmeState.managerHiddenObjectIds.has(record.id); object.innerHTML = `${escapeHtml(managedObjectLabel(record, index))}${layerManagerMoveButtons("oggetto")}›
`; const checkbox = object.querySelector("input"); checkbox.addEventListener("click", event => event.stopPropagation()); checkbox.addEventListener("change", () => setManagedObjectVisible(record, checkbox.checked, true)); object.querySelector(".layer-manager-title").addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); object.open = true; highlightManagedObject(record); }); const details = object.querySelector(".layer-manager-details"); const rows = managedObjectDetails(record.layer); details.innerHTML = rows.length ? rows.map(value => `${escapeHtml(value)}`).join("") : "Nessuna misura disponibile"; return object; } 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 === "gpsPosition") { lmeState.gpsPositionVisible = visible; } 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(); 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; } return true; } function highlightManagedObject(record) { lmeState.managerHighlight.clearLayers(); lmeState.managerHighlightedObjectId = record.id; lmeState.managerHighlightedCategoryKey = record.categoryKey; const geojson = record.layer.toGeoJSON?.(); if (geojson) { L.geoJSON(geojson, { 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); } fitLayer(record.layer); } function clearManagerHighlight() { lmeState.managerHighlight?.clearLayers(); lmeState.managerHighlightedObjectId = null; lmeState.managerHighlightedCategoryKey = null; } 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)); }); } function setImportedSheetLayerVisible(enabled) { migrateMisclassifiedImportedSheets(); lmeState.importedSheetsVisible = Boolean(enabled); const generated = lmeState.importedSheetsVisible && ensureImportedSheetOutline(); applyImportedSheetVisibility(); const count = lmeState.importedSheets?.toGeoJSON()?.features?.length || 0; setInfo(lmeState.importedSheetsVisible ? (count ? `${generated ? "Cornice generata dai dati importati" : "Layer del foglio importato visibile"} (${count} elementi).` : "Nessun dato importato da cui ricavare la cornice del foglio.") : "Layer del foglio importato nascosto; le particelle restano visibili."); } function ensureImportedSheetOutline() { if (!lmeState.imported || !lmeState.importedSheets) { return false; } const currentSheets = lmeState.importedSheets.toGeoJSON(); const hasAreaOutline = (currentSheets.features || []).some(feature => ["Polygon", "MultiPolygon"].includes(feature?.geometry?.type)); if (hasAreaOutline) { return false; } const imported = lmeState.imported.toGeoJSON(); const points = collectGeoJsonCoordinates(imported); if (!points.length) { return false; } const bounds = points.reduce((result, point) => ({ west: Math.min(result.west, point[0]), south: Math.min(result.south, point[1]), east: Math.max(result.east, point[0]), north: Math.max(result.north, point[1]) }), { west: Infinity, south: Infinity, east: -Infinity, north: -Infinity }); if (!Number.isFinite(bounds.west) || !Number.isFinite(bounds.south) || bounds.east <= bounds.west || bounds.north <= bounds.south) { return false; } const longitudePadding = Math.max((bounds.east - bounds.west) * 0.01, 0.000001); const latitudePadding = Math.max((bounds.north - bounds.south) * 0.01, 0.000001); const west = bounds.west - longitudePadding; const south = bounds.south - latitudePadding; const east = bounds.east + longitudePadding; const north = bounds.north + latitudePadding; addImportedSheetFeatures({ type: "FeatureCollection", features: [{ type: "Feature", properties: { name: "Cornice del foglio importato", _lmeGeneratedSheetOutline: true }, geometry: { type: "Polygon", coordinates: [[ [west, south], [east, south], [east, north], [west, north], [west, south] ]] } }] }); return true; } 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: bindStoredFeaturePopup }).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: bindStoredFeaturePopup }).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(); setInfo(lmeState.showImportedMeasurements ? "Misure dei file GIS visibili." : "Misure dei file GIS nascoste."); } function setDrawingMeasurementsVisible(enabled) { lmeState.showDrawingMeasurements = Boolean(enabled); refreshDrawingMeasurements(); 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() { clearManagedCategoryState("imported"); clearManagedCategoryState("importedSheets"); lmeState.imported.clearLayers(); lmeState.importedSheets.clearLayers(); lmeState.importedSheetsVisible = false; applyImportedSheetVisibility(); lmeState.importedMeasurements.clearLayers(); setInfo("File GIS rimossi."); updateGpsInsideState(); refreshLayerManager(); } function addProjectMarker(name) { const center = lmeState.map.getCenter(); const label = (name || "Nuovo POI").trim(); const marker = L.marker(center, { pane: "drawingsPane", draggable: true }).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(); refreshDrawingMeasurements(); setInfo(`POI aggiunto: ${label}`); refreshLayerManager(); } function configureDrawLabels() { L.drawLocal.draw.toolbar.buttons.polygon = "Misura area"; L.drawLocal.draw.toolbar.buttons.polyline = "Misura distanza"; L.drawLocal.draw.toolbar.buttons.circle = "Misura cerchio"; L.drawLocal.draw.toolbar.buttons.rectangle = "Disegna rettangolo"; L.drawLocal.draw.toolbar.buttons.marker = "Aggiungi POI"; L.drawLocal.edit.toolbar.buttons.edit = "Modifica disegni"; L.drawLocal.edit.toolbar.buttons.remove = "Elimina disegni"; 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 => { 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(); }); lmeState.map.on(L.Draw.Event.EDITED, event => { event.layers.eachLayer(layer => annotateDrawingLayer(layer)); refreshDrawingMeasurements(); updateGpsInsideState(); refreshLayerManager(); }); lmeState.map.on(L.Draw.Event.DELETED, () => { refreshDrawingMeasurements(); updateGpsInsideState(); refreshLayerManager(); }); 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", color: "#f97316", weight: 4, fillColor: "#fb923c", fillOpacity: 0.2 }; lmeState.drawControl = new L.Control.Draw({ position: "topright", edit: { featureGroup: lmeState.drawings }, draw: { circle: { metric: true, feet: false, showRadius: true, shapeOptions: orangeArea }, circlemarker: false, marker: { zIndexOffset: 0 }, polygon: { metric: true, feet: false, showArea: true, allowIntersection: false, shapeOptions: orangeArea }, polyline: { metric: true, feet: false, showLength: true, shapeOptions: { pane: "drawingsPane", color: "#f97316", weight: 4, opacity: 0.95 } }, rectangle: { metric: true, shapeOptions: orangeArea } } }); lmeState.map.addControl(lmeState.drawControl); bindDrawingEvents(); setInfo("Barra strumenti attiva: misure, disegni, POI, modifica e cancellazione."); } function startMeasurementDrawing(mode) { if (!lmeState.map) { return; } bindDrawingEvents(); if (lmeState.activeDrawHandler) { lmeState.activeDrawHandler.disable(); } const handlers = { area: () => new L.Draw.Polygon(lmeState.map, { allowIntersection: false, showArea: true, metric: true, feet: false, shapeOptions: { pane: "drawingsPane", color: "#f97316", weight: 4, fillColor: "#fb923c", fillOpacity: 0.2 } }), distance: () => new L.Draw.Polyline(lmeState.map, { metric: true, feet: false, showLength: true, shapeOptions: { pane: "drawingsPane", color: "#f97316", weight: 4, opacity: 0.95 } }), circle: () => new L.Draw.Circle(lmeState.map, { metric: true, feet: false, showRadius: true, shapeOptions: { pane: "drawingsPane", 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"; 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); } 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; 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; } lmeState.drawings.clearLayers(); clearManagedCategoryState("drawings"); lmeState.drawingMeasurements.clearLayers(); updateGpsInsideState(); setInfo("Disegni e POI eliminati dalla mappa."); refreshLayerManager(); } 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 => { if (context.count >= context.max || !layerIsInView(layer, context.bounds)) { return; } if (layer instanceof L.Circle) { renderCircleMeasurement(layer, targetGroup, context); } else if (layer instanceof L.Polygon) { renderPolygonMeasurement(layer, targetGroup, context); } else if (layer instanceof L.Polyline) { renderPolylineMeasurement(layer, targetGroup, context); } }); } 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) { 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(); 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 (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: context.sourceType === "drawing" ? "drawingsPane" : "importedPane", 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 (meters >= 1000) { return `${formatNumber(meters / 1000, 3)} km`; } return `${formatNumber(meters, meters < 100 ? 1 : 0)} m`; } function formatArea(squareMeters) { 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("it-IT", { minimumFractionDigits: 0, maximumFractionDigits: digits }).format(value); } 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]) ]) } }); } return JSON.stringify({ type: "FeatureCollection", features }); } 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: 3, 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, 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]) ]) }); } 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.gpsParcelLayer.clearLayers(); const restoredDrawings = L.geoJSON(data.drawings || emptyFeatureCollection(), { pane: "drawingsPane", style: { color: "#f97316", weight: 3, fillOpacity: 0.16 }, pointToLayer: (feature, latlng) => { const properties = feature?.properties || {}; if (properties._lmeShape === "circle" && Number(properties._lmeRadius) > 0) { return L.circle(latlng, { pane: "drawingsPane", radius: Number(properties._lmeRadius), color: "#f97316", weight: 3, fillColor: "#fb923c", fillOpacity: 0.2 }); } return L.marker(latlng, { pane: "drawingsPane", draggable: true }); }, onEachFeature: (feature, layer) => { bindStoredFeaturePopup(feature, layer); annotateDrawingLayer(layer, feature?.properties?._lmeShape); } }); 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); if (lmeState.importedSheetsVisible) { ensureImportedSheetOutline(); } applyImportedSheetVisibility(); L.geoJSON(data.selectedParcels || emptyFeatureCollection(), { pane: "selectedParcelsPane", style: { color: "#ef4444", weight: 4, opacity: 1, fillColor: "#fbbf24", fillOpacity: 0.26 }, 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)}`); } }).addTo(lmeState.selectedParcelsLayer); lmeState.gpsTrailPoints = Array.isArray(data.trail) ? data.trail : []; lmeState.gpsTrail.setLatLngs(lmeState.gpsTrailPoints); if (projectDisplayOptions) { applyProjectDisplayOptions(projectDisplayOptions); } refreshImportedMeasurements(); refreshDrawingMeasurements(); const projectLayerList = [ lmeState.drawings, lmeState.imported, lmeState.selectedParcelsLayer, 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(); 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, selectedParcelsVisible: lmeState.selectedParcelsVisible, layerOrder: [...lmeState.layerOrder], layerObjectOrder: { ...lmeState.managerObjectOrder }, hiddenLayerObjects: [...lmeState.managerHiddenObjectIds], 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 (Array.isArray(options.hiddenLayerObjects)) { lmeState.managerHiddenObjectIds = new Set(options.hiddenLayerObjects); } 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); setSelectedParcelsVisible(options.selectedParcelsVisible !== 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 existingIds = new Set(features.map(feature => feature?.properties?._lmeManagerId).filter(Boolean)); lmeState.managerHiddenObjects.forEach(record => { if (record.categoryKey !== categoryKey) { return; } const hidden = record.layer?.toGeoJSON?.(); const hiddenFeatures = hidden?.type === "FeatureCollection" ? hidden.features : hidden ? [hidden] : []; hiddenFeatures.forEach(feature => { const id = feature?.properties?._lmeManagerId; if (!id || !existingIds.has(id)) { features.push(feature); if (id) { existingIds.add(id); } } }); }); return { type: "FeatureCollection", features }; } function resetLayerManagerProjectState() { lmeState.managerHiddenObjects.clear(); lmeState.managerHiddenObjectIds.clear(); lmeState.managerHiddenCategories.clear(); lmeState.managerObjectOrder = {}; clearManagerHighlight(); } function clearManagedCategoryState(categoryKey) { [...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) { const properties = feature?.properties || {}; const label = properties.name || properties.Name || properties.label || properties.LABEL; if (label) { layer.bindPopup(`${escapeHtml(label)}`); } } 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", "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); lmeState.map.attributionControl.addAttribution(lmeState.azureAttribution); } function clearAzureAttribution() { if (lmeState.azureAttribution) { lmeState.map.attributionControl.removeAttribution(lmeState.azureAttribution); 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); } } 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(); 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 refreshCadastreLayer() { if (lmeState.cadastre) { lmeState.map.removeLayer(lmeState.cadastre); } const layers = activeCadastreLayers(); lmeState.cadastre = null; if (!layers.length) { return; } 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" }); let errorShown = false; cadastreLayer.on("tileerror", () => { if (errorShown) { return; } errorShown = true; setInfo("Layer catastali non caricati dal servizio AdE. Controlla la connessione e riprova."); }); cadastreLayer.on("load", () => { errorShown = false; }); lmeState.cadastre = cadastreLayer.addTo(lmeState.map); } function activeCadastreLayers() { const drawOrder = [ "CP.CadastralZoning", "strade", "acque", "fabbricati", "CP.CadastralParcel", "codice_plla" ]; return drawOrder.filter(layer => lmeState.cadastreLayers.includes(layer)); } function clearSelectedParcels() { const count = managedGroupGeoJson(lmeState.selectedParcelsLayer, "selectedParcels").features.length; lmeState.selectedParcelsLayer.clearLayers(); clearManagedCategoryState("selectedParcels"); updateGpsInsideState(); setInfo(count ? `${count} particelle selezionate rimosse.` : "Non ci sono particelle selezionate da rimuovere."); refreshLayerManager(); } 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.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, file importati, disegni, POI e traccia GPS dalla mappa?")) { return; } resetLayerManagerProjectState(); lmeState.selectedParcelsLayer.clearLayers(); lmeState.gpsParcelLayer.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(); } 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) { setInfo("Registrazione percorso disattivata."); } } 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); } if (lmeState.gpsTrailEnabled) { lmeState.gpsTrailPoints.push(latlng); lmeState.gpsTrail.setLatLngs(lmeState.gpsTrailPoints); } 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: "gpsPositionPane", 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]]; 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 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", "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("", ">") .replaceAll('"', """) .replaceAll("'", "'"); }