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,
drawingsRenderer: null,
drawingSelection: null,
drawingMeasurements: null,
drawControl: null,
drawingDeleteButton: null,
drawingScopeMenu: null,
ignoreMapClickUntil: 0,
activeDrawHandler: null,
drawEventsBound: false,
includeKmlSheetLayer: false,
showImportedMeasurements: false,
showDrawingMeasurements: true,
measurementRefreshTimer: null,
gpsMarker: null,
gpsAccuracy: null,
gpsPositionVisible: true,
hasLiveGpsFix: false,
lastAccuracy: 0,
gpsTrail: null,
selectedParcelsLayer: null,
selectedParcelsVisible: true,
gpsParcelLayer: null,
gpsTrailEnabled: false,
gpsTrailPoints: [],
gpsTrailStartedAt: null,
gpsWatchId: null,
lastPosition: null,
fileImportBound: false,
resizeBound: false,
googleMapsLoading: null,
azureMapsKey: null,
azureAttribution: null,
managerHighlight: null,
managerHighlightedObjectId: null,
managerHighlightedCategoryKey: null,
managerHighlightedParentKey: null,
managerHighlightedParentRecords: null,
managerHoveredParentKey: null,
managerHoveredParentRecords: null,
managerOutsideSelectionBound: false,
managerHiddenObjects: new Map(),
managerHiddenObjectIds: new Set(),
managerHiddenMeasurementSummaryIds: new Set(),
managerHiddenMeasurementSideIds: new Set(),
managerHiddenCategories: new Set(),
managerRemovedCategories: new Set(),
managerObjectOrder: {},
managerCategoryNames: {},
managerExpandedRows: new Set(),
managerSelectedObjectIds: new Set(),
managerSearchText: "",
permanentLabels: null,
undoStack: [],
redoStack: [],
historyBusy: false,
snapEnabled: true,
snapDistancePixels: 14,
autoSaveEnabled: true,
gpsProximityEnabled: true,
gpsBoundaryAlertEnabled: false,
lastBoundaryState: null,
trash: [],
auditLog: [],
isDirty: false,
readOnly: false,
changeNotifyTimer: null,
offlineDbPromise: null,
offlineTileCount: 0,
dotNetReference: null
};
// 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,
clearLayerManagerSelection: clearManagerHighlight,
setImportedMeasurementsVisible,
setDrawingMeasurementsVisible,
addProjectMarker,
startMeasurementDrawing,
setDrawToolsVisible,
clearDrawings,
undoMapChange,
redoMapChange,
getHistoryState,
markProjectSaved,
hasUnsavedChanges: () => lmeState.isDirty,
confirmDiscardChanges,
setProjectReadOnly,
restoreAutosave,
compareProjectSnapshots,
setAdvancedOptions,
createMapExportGeoJson,
createProjectSnapshot,
loadProjectSnapshot,
getLegacyProjectSnapshot,
clearLegacyProjectSnapshot,
setBaseLayer,
configureGoogleMaps,
configureAzureMaps,
clearAzureMaps,
setCadastreOpacity,
toggleCadastreLayer,
startGps,
stopGps,
centerOnGps,
toggleTrail,
clearGpsTrail,
getGpsTrailStats,
setNativeGpsPosition,
setGpsPositionVisible,
setGpsError,
saveGpsMeasurementPoint,
restoreLastTrashItem,
emptyTrash,
showObjectTable,
showAuditLog,
showCoordinateInspector,
validateAndRepairSelected,
createHoleFromSelected,
compareSelectedGeometries,
downloadOfflineArea,
clearOfflineTiles,
getOfflineTileStats,
resize,
showInfo: setInfo
};
function init(dotNetReference) {
if (dotNetReference) {
lmeState.dotNetReference = dotNetReference;
}
if (lmeState.map) {
return;
}
lmeState.map = L.map("map", {
zoomControl: false,
attributionControl: false
}).setView([41.9028, 12.4964], 17);
L.control.zoom({ position: "bottomright" }).addTo(lmeState.map);
lmeState.baseLayers.osm = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxNativeZoom: 19,
maxZoom: 22,
attribution: "© OpenStreetMap contributors"
});
lmeState.baseLayers.satellite = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", {
maxNativeZoom: 19,
maxZoom: 22,
attribution: "Tiles © Esri"
});
lmeState.baseLayers.topographic = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", {
maxNativeZoom: 19,
maxZoom: 22,
attribution: "Tiles © Esri"
});
lmeState.baseLayers["streets-esri"] = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}", {
maxNativeZoom: 19,
maxZoom: 22,
attribution: "Tiles © Esri"
});
lmeState.baseLayers.terrain = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", {
maxNativeZoom: 17,
maxZoom: 22,
attribution: "Map data © OpenStreetMap contributors, SRTM | Map style © OpenTopoMap"
});
lmeState.baseLayers.carto = L.tileLayer("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", {
subdomains: "abcd",
maxNativeZoom: 20,
maxZoom: 22,
attribution: "© OpenStreetMap contributors © CARTO"
});
lmeState.baseLayers.offline = createOfflineTileLayer();
lmeState.currentBase = lmeState.baseLayers.satellite.addTo(lmeState.map);
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";
lmeState.drawingsRenderer = L.canvas({ pane: "drawingsPane", tolerance: 14 });
setLayerOrder(lmeState.layerOrder);
lmeState.imported = L.featureGroup().addTo(lmeState.map);
lmeState.importedSheets = L.featureGroup();
lmeState.drawings = L.featureGroup().addTo(lmeState.map);
lmeState.drawingSelection = L.featureGroup();
lmeState.importedMeasurements = L.layerGroup().addTo(lmeState.map);
lmeState.drawingMeasurements = L.layerGroup().addTo(lmeState.map);
lmeState.selectedParcelsLayer = L.featureGroup().addTo(lmeState.map);
lmeState.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);
lmeState.permanentLabels = 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 => {
if (event.originalEvent?._lmeDrawingSelected || Date.now() < lmeState.ignoreMapClickUntil) {
return;
}
hideDrawingScopeMenu();
clearManagerHighlight();
const info = await queryFeatureInfo(event.latlng);
if (info) {
L.popup().setLatLng(event.latlng).setContent(info).openOn(lmeState.map);
}
});
lmeState.map.on("moveend", () => {
void updateAzureAttribution();
scheduleMeasurementRefresh();
});
document.getElementById("closeWmsPreview")?.addEventListener("click", closeCadastrePreview);
document.getElementById("wmsPreview")?.addEventListener("click", event => {
if (event.target?.id === "wmsPreview") {
closeCadastrePreview();
}
});
document.addEventListener("keydown", event => {
if (event.key === "Escape") {
closeCadastrePreview();
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === "z") {
event.preventDefault();
undoMapChange();
} else if ((event.ctrlKey || event.metaKey) && (event.key.toLowerCase() === "y" || (event.shiftKey && event.key.toLowerCase() === "z"))) {
event.preventDefault();
redoMapChange();
}
});
window.addEventListener("beforeunload", event => {
if (!lmeState.isDirty) return;
event.preventDefault();
event.returnValue = "";
});
if (!lmeState.managerOutsideSelectionBound) {
document.addEventListener("pointerdown", event => {
if (lmeState.drawingScopeMenu
&& !event.target?.closest?.(".lme-drawing-scope-actions")
&& !event.target?.closest?.(".leaflet-draw-edit-edit, .leaflet-draw-edit-remove")) {
hideDrawingScopeMenu();
}
if (!lmeState.managerHighlightedObjectId && !lmeState.managerHighlightedParentKey) {
return;
}
if (event.target?.closest?.(".leaflet-draw")) {
return;
}
const selectedObject = event.target?.closest?.(".layer-manager-object");
const selectedParent = event.target?.closest?.("[data-highlight-key]");
if (selectedObject?.dataset.objectId === lmeState.managerHighlightedObjectId
|| selectedParent?.dataset.highlightKey === lmeState.managerHighlightedParentKey) {
return;
}
clearManagerHighlight();
});
lmeState.managerOutsideSelectionBound = true;
}
setInfo("Mappa pronta: satellite + catasto AdE");
}
function 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 {
if (file.size > 100 * 1024 * 1024) throw new Error("file superiore a 100 MB; suddividilo prima dell'importazione");
setInfo(`Analisi di ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)...`);
await new Promise(resolve => window.setTimeout(resolve, 20));
const result = await readGisFile(file);
rememberMapChange();
const geojson = result.geojson;
const parcelLayer = addImportedFeatures(geojson);
const sheetGeojson = result.sheetGeojson || emptyFeatureCollection();
const sheetLayer = addImportedSheetFeatures(sheetGeojson);
applyImportedSheetVisibility();
fitLayer(geojson.features.length ? parcelLayer : sheetLayer);
refreshImportedMeasurements();
const featureCount = geojson.features.length;
const sheetCount = sheetGeojson.features.length;
const ignored = result.ignoredCount
? `; esclusi ${result.ignoredCount} elementi del foglio o non particella`
: "";
const includedSheets = sheetCount
? `; ${sheetCount} elementi foglio ${lmeState.importedSheetsVisible ? "visibili" : "nascosti"}`
: "";
const reprojected = result.reprojected ? "; coordinate Web Mercator convertite" : "";
const warnings = result.validationWarnings ? `; ${result.validationWarnings} geometrie da verificare` : "";
setInfo(`Caricato ${file.name}: ${featureCount} particelle/elementi${includedSheets}${ignored}${reprojected}${warnings}.`);
updateGpsInsideState();
refreshLayerManager();
finishMapChange();
} 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)})`);
}
let validationWarnings = 0;
const unique = new Set();
root.features = root.features.filter(feature => {
try { turf.cleanCoords(feature, { mutate: true }); } catch { }
const key = JSON.stringify(feature.geometry);
if (unique.has(key)) return false;
unique.add(key);
if (typeof turf.booleanValid === "function" && !turf.booleanValid(feature)) {
feature.properties._lmeValidationWarning = "Geometria non valida: usa Controlla e ripara";
validationWarnings++;
}
return true;
});
if (root.features.length > 5000 && window.confirm(`Il file contiene ${root.features.length} geometrie. Semplificarle leggermente per migliorare le prestazioni?`)) {
root.features = root.features.map(feature => {
try { return turf.simplify(feature, { tolerance: 0.000001, highQuality: true, mutate: false }); } catch { return feature; }
});
}
const normalizedCoordinates = collectGeoJsonCoordinates(root);
if (!normalizedCoordinates.length || normalizedCoordinates.some(point =>
!Number.isFinite(point[0])
|| !Number.isFinite(point[1])
|| Math.abs(point[0]) > 180
|| Math.abs(point[1]) > 90)) {
throw new Error("coordinate non valide o non espresse in WGS84/EPSG:4326");
}
delete root.crs;
return {
geojson: root,
sheetGeojson: emptyFeatureCollection(),
ignoredCount: 0,
reprojected: webMercator,
validationWarnings
};
}
function isGeoJsonGeometry(value) {
return [
"Point",
"MultiPoint",
"LineString",
"MultiLineString",
"Polygon",
"MultiPolygon",
"GeometryCollection"
].includes(value?.type);
}
function collectGeoJsonCoordinates(geojson) {
const points = [];
const visitGeometry = geometry => {
if (!geometry) {
return;
}
if (geometry.type === "GeometryCollection") {
(geometry.geometries || []).forEach(visitGeometry);
return;
}
const visit = coordinates => {
if (Array.isArray(coordinates)
&& coordinates.length >= 2
&& typeof coordinates[0] === "number"
&& typeof coordinates[1] === "number") {
points.push(coordinates);
return;
}
if (Array.isArray(coordinates)) {
coordinates.forEach(visit);
}
};
visit(geometry.coordinates);
};
(geojson.features || []).forEach(feature => visitGeometry(feature.geometry));
return points;
}
function transformGeoJsonGeometry(geometry, transform) {
if (!geometry) {
return geometry;
}
if (geometry.type === "GeometryCollection") {
return {
...geometry,
geometries: (geometry.geometries || []).map(item => transformGeoJsonGeometry(item, transform))
};
}
const visit = coordinates => {
if (Array.isArray(coordinates)
&& coordinates.length >= 2
&& typeof coordinates[0] === "number"
&& typeof coordinates[1] === "number") {
return transform(coordinates);
}
return Array.isArray(coordinates) ? coordinates.map(visit) : coordinates;
};
return { ...geometry, coordinates: visit(geometry.coordinates) };
}
function webMercatorToWgs84(coordinate) {
const longitude = coordinate[0] * 180 / 20037508.34;
const latitudeRadians = Math.PI / 2 - 2 * Math.atan(Math.exp(-coordinate[1] / 6378137));
return [longitude, latitudeRadians * 180 / Math.PI, ...coordinate.slice(2)];
}
function normalizeKmlFiles(files) {
const features = files.flatMap(file => kmlTextToGeoJson(file.text, file.name));
if (!features.length) {
throw new Error("KML senza geometrie utilizzabili");
}
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") {
const sourceProperties = node.properties || {};
node.properties = {
...sourceProperties,
_lmeColor: sourceProperties._lmeColor || sourceProperties.stroke,
_lmeFillColor: sourceProperties._lmeFillColor || sourceProperties.fill,
_lmeWeight: sourceProperties._lmeWeight || sourceProperties["stroke-width"],
_lmeOpacity: sourceProperties._lmeOpacity || sourceProperties["stroke-opacity"],
_lmeFillOpacity: sourceProperties._lmeFillOpacity || sourceProperties["fill-opacity"],
_lmeFolder: folders.filter(Boolean).join(" / "),
_lmeSource: sourceName
};
features.push(node);
return;
}
const nextFolders = node?.type === "folder"
? [...folders, node.meta?.name || ""]
: folders;
(node?.children || []).forEach(child => visit(child, nextFolders));
};
visit(tree, []);
return features;
}
function filterKmlParcelFeatures(features) {
const filtered = features.filter(feature => {
if (isSheetOrOverlayFeature(feature)) {
return false;
}
const properties = feature.properties || {};
const name = String(properties.name || properties.Name || properties.LABEL || "").trim();
const identity = [name, properties.layer, properties.Layer, properties.type, properties.Tipo]
.filter(Boolean)
.join(" ")
.toLowerCase();
const folder = String(properties._lmeFolder || "").toLowerCase();
const context = `${identity} ${folder}`;
const hasParcelNumber = /^[a-z]?\s*\d+[a-z]?(?:[\/. -]\d+[a-z]?)?$/i.test(name);
const isSheet = /\bfoglio\b|\bsheet\b|quadro[ _-]?d[' ]?unione|(?:perimetro|bordo|limite|contorno)[ _-]?(?:del[ _-]?)?foglio|sheet[ _-]?boundary/.test(context);
return !isSheet || hasParcelNumber;
});
return removeLikelySheetOutlines(filtered);
}
function isSheetOrOverlayFeature(feature) {
const properties = feature?.properties || {};
const geometryType = String(properties["@geometry-type"] || "").toLowerCase();
const name = String(properties.name || properties.Name || properties.LABEL || "").trim();
const context = [
name,
properties.layer,
properties.Layer,
properties.type,
properties.Tipo,
properties._lmeFolder
].filter(Boolean).join(" ").toLowerCase();
if (geometryType === "groundoverlay" || /^doc[_ -]?\d+$/i.test(name)) {
return true;
}
const hasParcelNumber = /^[a-z]?\s*\d+[a-z]?(?:[\/. -]\d+[a-z]?)?$/i.test(name);
return !hasParcelNumber
&& /\bfoglio\b|\bsheet\b|quadro[ _-]?d[' ]?unione|(?:perimetro|bordo|limite|contorno)[ _-]?(?:del[ _-]?)?foglio|sheet[ _-]?boundary/.test(context);
}
function splitKmlPolygonFeatures(features) {
const result = [];
const addGeometry = (feature, geometry, partIndex) => {
if (!geometry) {
return;
}
if (geometry.type === "Polygon") {
result.push({
...feature,
properties: { ...(feature.properties || {}), _lmePart: partIndex },
geometry
});
return;
}
if (geometry.type === "MultiPolygon") {
(geometry.coordinates || []).forEach((coordinates, index) => addGeometry(
feature,
{ type: "Polygon", coordinates },
`${partIndex}.${index}`));
return;
}
if (geometry.type === "GeometryCollection") {
(geometry.geometries || []).forEach((item, index) => addGeometry(feature, item, `${partIndex}.${index}`));
}
};
features.forEach((feature, index) => addGeometry(feature, feature.geometry, index));
return result;
}
function removeLikelySheetOutlines(features) {
let remaining = [...features];
while (remaining.length >= 2) {
const candidates = remaining
.map(feature => ({ feature, bounds: geoJsonBounds(feature.geometry) }))
.filter(item => item.bounds);
if (candidates.length < 2) {
break;
}
const largest = candidates.reduce((current, item) =>
boundsArea(item.bounds) > boundsArea(current.bounds) ? item : current);
if (isStrongParcelFeature(largest.feature)) {
break;
}
const others = candidates.filter(item => item !== largest);
const comparisonAreas = others.map(item => boundsArea(item.bounds)).sort((a, b) => a - b);
const referenceArea = comparisonAreas[Math.floor(comparisonAreas.length / 2)];
const contained = others.filter(item => boundsContains(largest.bounds, boundsCenter(item.bounds))).length;
const minimumContained = candidates.length === 2 ? 1 : Math.ceil(others.length * 0.6);
const areaFactor = 2.5;
const looksLikeSheet = referenceArea > 0
&& boundsArea(largest.bounds) > referenceArea * areaFactor
&& contained >= minimumContained;
if (!looksLikeSheet) {
break;
}
remaining = remaining.filter(feature => feature !== largest.feature);
}
return remaining;
}
function isStrongParcelFeature(feature) {
const properties = feature?.properties || {};
const name = String(properties.name || properties.Name || properties.LABEL || "").trim();
const context = [name, properties.layer, properties.Layer, properties.type, properties.Tipo, properties._lmeFolder]
.filter(Boolean)
.join(" ");
return /particell|mappal|parcel/i.test(context)
|| /^[a-z]?\s*\d+[a-z]?(?:[\/. -]\d+[a-z]?)?$/i.test(name);
}
function geoJsonBounds(geometry) {
const wrapper = { features: [{ geometry }] };
const points = collectGeoJsonCoordinates(wrapper);
if (!points.length) {
return null;
}
return points.reduce((bounds, point) => ({
west: Math.min(bounds.west, point[0]),
south: Math.min(bounds.south, point[1]),
east: Math.max(bounds.east, point[0]),
north: Math.max(bounds.north, point[1])
}), { west: Infinity, south: Infinity, east: -Infinity, north: -Infinity });
}
function boundsArea(bounds) {
return Math.max(0, bounds.east - bounds.west) * Math.max(0, bounds.north - bounds.south);
}
function boundsCenter(bounds) {
return [(bounds.west + bounds.east) / 2, (bounds.south + bounds.north) / 2];
}
function boundsContains(bounds, point) {
return point[0] >= bounds.west && point[0] <= bounds.east
&& point[1] >= bounds.south && point[1] <= bounds.north;
}
function setIncludeKmlSheetLayer(enabled) {
lmeState.includeKmlSheetLayer = Boolean(enabled);
setInfo(lmeState.includeKmlSheetLayer
? "Importazione KML/KMZ: verranno inclusi anche foglio e altri elementi."
: "Importazione KML/KMZ: verranno caricate solo le particelle riconosciute.");
}
function setImportedLayerVisible(enabled) {
lmeState.importedVisible = Boolean(enabled);
if (!lmeState.map || !lmeState.imported) {
return;
}
if (lmeState.importedVisible) {
if (!lmeState.map.hasLayer(lmeState.imported)) {
lmeState.imported.addTo(lmeState.map);
}
lmeState.imported.bringToFront();
const count = lmeState.imported.toGeoJSON().features?.length || 0;
setInfo(count
? `${count} particelle o geometrie importate visibili.`
: "Nessun dato importato da mostrare.");
return;
}
if (lmeState.map.hasLayer(lmeState.imported)) {
lmeState.map.removeLayer(lmeState.imported);
}
setInfo("Dati importati nascosti; restano disponibili nel file o progetto.");
}
function managedLayerPanes() {
return {
gpsPosition: "gpsPositionPane",
drawings: "drawingsPane",
gpsTrail: "gpsTrailPane",
gpsParcels: "gpsParcelsPane",
selectedParcels: "selectedParcelsPane",
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 managedObjectEditableName(record) {
const properties = record?.layer?.feature?.properties || {};
return properties.name || properties.Name || properties.label || properties.LABEL || "";
}
function managedObjectCanRename(record) {
return ["drawings", "imported", "importedSheets"].includes(record.categoryKey)
&& record.layer?.feature?.properties;
}
function renameManagedObject(record, index) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (!managedObjectCanRename(record)) {
return;
}
const currentName = String(managedObjectEditableName(record) || managedObjectLabel(record, index)).trim();
const requestedName = window.prompt("Nuovo nome oggetto", currentName);
if (requestedName === null) {
return;
}
const name = requestedName.trim().slice(0, 120);
if (!name) {
setInfo("Nome non modificato: inserisci un testo.");
return;
}
rememberMapChange();
const properties = record.layer.feature.properties;
properties.name = name;
record.layer.lmeManagerLabel = record.categoryKey === "drawings" && record.layer instanceof L.Marker
? `POI: ${name}`
: null;
updateStoredFeaturePopup(record.layer);
refreshImportedMeasurements();
refreshDrawingMeasurements();
refreshPermanentLabels();
refreshLayerManager();
finishMapChange();
setInfo(`Oggetto rinominato: ${name}.`);
}
function managedCategoryName(category, records) {
const storedName = lmeState.managerCategoryNames[category.key];
const featureName = records
.map(record => record.layer?.feature?.properties?._lmeCategoryName)
.find(Boolean);
const name = String(storedName || featureName || category.label).trim();
if (name !== category.label) {
lmeState.managerCategoryNames[category.key] = name;
}
return name;
}
function managedCategoryCanRename(category) {
return ["drawings", "imported", "importedSheets"].includes(category.key);
}
function renameManagedCategory(category, records) {
const currentName = managedCategoryName(category, records);
const requestedName = window.prompt("Nuovo nome gruppo", currentName);
if (requestedName === null) {
return;
}
const name = requestedName.trim().slice(0, 120);
if (!name) {
setInfo("Nome non modificato: inserisci un testo.");
return;
}
rememberMapChange();
lmeState.managerCategoryNames[category.key] = name;
records.forEach(record => {
if (record.layer?.feature?.properties) {
record.layer.feature.properties._lmeCategoryName = name;
}
});
refreshLayerManager();
finishMapChange();
setInfo(`Gruppo rinominato: ${name}.`);
}
function managedBlockName(source, records) {
return String(records
.map(record => record.layer?.feature?.properties?._lmeGroupName)
.find(Boolean) || source);
}
function renameManagedBlock(category, source, records) {
const currentName = managedBlockName(source, records);
const requestedName = window.prompt("Nuovo nome gruppo", currentName);
if (requestedName === null) {
return;
}
const name = requestedName.trim().slice(0, 120);
if (!name) {
setInfo("Nome non modificato: inserisci un testo.");
return;
}
rememberMapChange();
records.forEach(record => {
if (record.layer?.feature?.properties) {
record.layer.feature.properties._lmeGroupName = name;
}
});
refreshLayerManager();
finishMapChange();
setInfo(`Gruppo rinominato: ${name}.`);
}
function managedCategoryCanRemove(category, records) {
return records.length > 0
&& ["drawings", "imported", "importedSheets", "selectedParcels", "gpsParcels"].includes(category.key);
}
function managedObjectCanRemove(record) {
return ["drawings", "imported", "importedSheets", "selectedParcels", "gpsParcels"].includes(record.categoryKey);
}
function layerManagerRowActions(renameButton, removeButton) {
if (!renameButton && !removeButton) {
return "";
}
return `${renameButton}${removeButton}`;
}
const layerManagerPalettes = [
{ surface: "#f0fdf4", parent: "#dcfce7", border: "#86efac", accent: "#166534" },
{ surface: "#eff6ff", parent: "#dbeafe", border: "#93c5fd", accent: "#1d4ed8" },
{ surface: "#fffbeb", parent: "#fef3c7", border: "#fcd34d", accent: "#92400e" },
{ surface: "#fff1f2", parent: "#ffe4e6", border: "#fda4af", accent: "#be123c" },
{ surface: "#f5f3ff", parent: "#ede9fe", border: "#c4b5fd", accent: "#6d28d9" },
{ surface: "#ecfeff", parent: "#cffafe", border: "#67e8f9", accent: "#0e7490" },
{ surface: "#f7fee7", parent: "#ecfccb", border: "#bef264", accent: "#3f6212" }
];
function applyLayerManagerPalette(row, key) {
const hash = [...String(key)].reduce((value, character) =>
((value * 31) + character.charCodeAt(0)) >>> 0, 0);
const palette = layerManagerPalettes[hash % layerManagerPalettes.length];
row.style.setProperty("--layer-group-surface", palette.surface);
row.style.setProperty("--layer-group-parent", palette.parent);
row.style.setProperty("--layer-group-border", palette.border);
row.style.setProperty("--layer-group-accent", palette.accent);
}
function clearManagedObjectState(id) {
lmeState.managerHiddenObjects.delete(id);
lmeState.managerHiddenObjectIds.delete(id);
lmeState.managerHiddenMeasurementSummaryIds.delete(id);
lmeState.managerHiddenMeasurementSideIds.delete(id);
Object.keys(lmeState.managerObjectOrder).forEach(categoryKey => {
lmeState.managerObjectOrder[categoryKey] = lmeState.managerObjectOrder[categoryKey]
.filter(objectId => objectId !== id);
});
}
function removeManagedRecords(records, subject) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (!records.length) {
return;
}
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) {
setInfo("Operazione annullata: sblocca prima tutti gli oggetti coinvolti.");
return;
}
const message = records.length === 1
? `Rimuovere ${subject} dal progetto? Non verra incluso nel prossimo salvataggio.`
: `Rimuovere ${subject} e tutti i suoi ${records.length} oggetti dal progetto? Non verranno inclusi nel prossimo salvataggio.`;
if (!window.confirm(message)) {
return;
}
rememberMapChange();
lmeState.trash.push({
removedAt: new Date().toISOString(),
subject,
items: records.map(record => ({
categoryKey: record.categoryKey,
source: record.source,
feature: record.layer.toGeoJSON()
}))
});
if (lmeState.trash.length > 30) lmeState.trash.shift();
const removedIds = new Set(records.map(record => record.id));
const categoryKeys = new Set(records.map(record => record.categoryKey));
records.forEach(record => {
if (record.parent?.hasLayer?.(record.layer)) {
record.parent.removeLayer(record.layer);
}
clearManagedObjectState(record.id);
});
categoryKeys.forEach(categoryKey => {
const order = lmeState.managerObjectOrder[categoryKey] || [];
lmeState.managerObjectOrder[categoryKey] = order.filter(id => !removedIds.has(id));
const category = layerManagerCategories().find(item => item.key === categoryKey);
if (category && managedRecordsForCategory(category).length === 0) {
lmeState.managerRemovedCategories.add(categoryKey);
delete lmeState.managerCategoryNames[categoryKey];
}
});
clearManagerHighlight();
refreshImportedMeasurements();
refreshDrawingMeasurements();
updateGpsInsideState();
refreshLayerManager();
finishMapChange(`Rimozione: ${subject}`);
setInfo(`${records.length === 1 ? "Oggetto rimosso" : `${records.length} oggetti rimossi`} dal progetto.`);
}
function 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;
}
captureLayerManagerExpansion(container);
const scrollHost = container.closest(".map-layer-panel");
const scrollTop = scrollHost?.scrollTop || 0;
applyStoredLayerManagerVisibility();
container.innerHTML = "";
lmeState.managerRecords = new Map();
container.appendChild(renderLayerSelectionToolbar());
const categoriesByKey = new Map(layerManagerCategories().map(category => [category.key, category]));
lmeState.layerOrder.forEach(categoryKey => {
const category = categoriesByKey.get(categoryKey);
if (!category) {
return;
}
const records = managedRecordsForCategory(category);
if (records.length) {
lmeState.managerRemovedCategories.delete(category.key);
} else if (lmeState.managerRemovedCategories.has(category.key)) {
return;
}
const savedOrder = lmeState.managerObjectOrder[category.key] || [];
const positions = new Map(savedOrder.map((id, index) => [id, index]));
records.sort((left, right) =>
(positions.get(left.id) ?? Number.MAX_SAFE_INTEGER) - (positions.get(right.id) ?? Number.MAX_SAFE_INTEGER));
records.forEach(record => lmeState.managerRecords.set(record.id, record));
const rendered = renderLayerManagerCategory(category, records);
const search = lmeState.managerSearchText.trim().toLocaleLowerCase("it");
if (search && !records.some((record, index) => `${managedObjectLabel(record, index)} ${record.source}`.toLocaleLowerCase("it").includes(search))
&& !managedCategoryName(category, records).toLocaleLowerCase("it").includes(search)) {
rendered.hidden = true;
}
container.appendChild(rendered);
});
applyManagedVisualOrder(container);
bindLayerManagerDrag(container);
bindLayerManagerMoveButtons(container);
if (scrollHost) {
scrollHost.scrollTop = scrollTop;
window.requestAnimationFrame(() => {
scrollHost.scrollTop = scrollTop;
});
}
}
function renderLayerManagerCategory(category, records) {
const group = document.createElement("details");
group.className = "layer-manager-group";
group.dataset.dragType = "category";
group.dataset.layerDepth = "0";
group.dataset.categoryKey = category.key;
applyLayerManagerPalette(group, `category:${category.key}`);
bindLayerManagerExpansion(group, `category:${category.key}`);
const categoryName = managedCategoryName(category, records);
const renameButton = managedCategoryCanRename(category)
? ``
: "";
const removeButton = managedCategoryCanRemove(category, records)
? ``
: "";
const rowActions = layerManagerRowActions(renameButton, removeButton);
group.classList.toggle("layer-manager-with-actions", Boolean(rowActions));
const visibleCount = records.filter(record => !lmeState.managerHiddenObjectIds.has(record.id)).length;
const categoryVisible = !lmeState.managerHiddenCategories.has(category.key)
&& (!category.roots.length || category.roots.some(layer => lmeState.map.hasLayer(layer)));
const visible = categoryVisible;
const categoryLocked = records.length > 0 && records.every(record => Boolean(record.layer?.feature?.properties?._lmeLocked));
group.innerHTML = `${escapeHtml(categoryName)}${rowActions}${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));
group.querySelector(".layer-inline-lock")?.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
updateManagedProperties(records, properties => { properties._lmeLocked = !categoryLocked; });
});
group.querySelector(".layer-rename-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
renameManagedCategory(category, records);
});
group.querySelector(".layer-remove-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
removeManagedRecords(records, `il gruppo '${categoryName}'`);
});
bindManagedParentHighlight(group, `category:${category.key}`, records);
const children = group.querySelector(".layer-manager-children");
if (records.length) children.appendChild(renderManagedGroupTools(records));
if (category.blocks) {
const blocks = new Map();
records.forEach(record => {
const list = blocks.get(record.source) || [];
list.push(record);
blocks.set(record.source, list);
});
blocks.forEach((blockRecords, source) => children.appendChild(renderLayerManagerBlock(category, source, blockRecords)));
} else {
records.forEach((record, index) => children.appendChild(renderLayerManagerObject(record, index)));
}
return group;
}
function renderLayerManagerBlock(category, source, records) {
const block = document.createElement("details");
block.className = "layer-manager-block";
block.dataset.dragType = "block";
block.dataset.layerDepth = "1";
block.dataset.categoryKey = category.key;
block.dataset.blockKey = source;
applyLayerManagerPalette(block, `block:${category.key}:${source}`);
bindLayerManagerExpansion(block, `block:${category.key}:${source}`);
const visibleCount = records.filter(record => !lmeState.managerHiddenObjectIds.has(record.id)).length;
const visible = visibleCount === records.length;
const blockName = managedBlockName(source, records);
const rowActions = layerManagerRowActions(
``,
``);
block.classList.add("layer-manager-with-actions");
const blockLocked = records.length > 0 && records.every(record => Boolean(record.layer?.feature?.properties?._lmeLocked));
block.innerHTML = `${escapeHtml(blockName)}${rowActions}${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();
});
block.querySelector(".layer-inline-lock")?.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
updateManagedProperties(records, properties => { properties._lmeLocked = !blockLocked; });
});
block.querySelector(".layer-rename-button").addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
renameManagedBlock(category, source, records);
});
block.querySelector(".layer-remove-button").addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
removeManagedRecords(records, `il gruppo '${blockName}'`);
});
bindManagedParentHighlight(block, `block:${category.key}:${source}`, records);
const children = block.querySelector(".layer-manager-children");
children.appendChild(renderManagedGroupTools(records));
records.forEach((record, index) => children.appendChild(renderLayerManagerObject(record, index)));
return block;
}
function renderLayerManagerObject(record, index) {
const object = document.createElement("details");
object.className = "layer-manager-object";
if (record.id === lmeState.managerHighlightedObjectId) {
object.classList.add("layer-manager-object-selected");
}
object.dataset.dragType = "object";
object.dataset.layerDepth = "2";
object.dataset.objectId = record.id;
bindLayerManagerExpansion(object, `object:${record.id}`);
const visible = !lmeState.managerHiddenObjectIds.has(record.id);
const renameButton = managedObjectCanRename(record)
? ``
: "";
const removeButton = managedObjectCanRemove(record)
? ``
: "";
const rowActions = layerManagerRowActions(renameButton, removeButton);
object.classList.toggle("layer-manager-with-actions", Boolean(rowActions));
const objectName = managedObjectLabel(record, index);
const locked = Boolean(record.layer?.feature?.properties?._lmeLocked);
object.innerHTML = `${escapeHtml(objectName)}${rowActions}${layerManagerMoveButtons("oggetto")}โบ`;
const checkbox = object.querySelector("input");
checkbox.addEventListener("click", event => event.stopPropagation());
checkbox.addEventListener("change", () => setManagedObjectVisible(record, checkbox.checked, true));
object.querySelector(".layer-rename-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
renameManagedObject(record, index);
});
object.querySelector(".layer-remove-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
removeManagedRecords([record], `'${objectName}'`);
});
object.querySelector(".layer-inline-lock")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
updateManagedProperties([record], value => { value._lmeLocked = !locked; });
});
object.querySelector(".layer-manager-title").addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
object.open = true;
highlightManagedObject(record);
});
const details = object.querySelector(".layer-manager-details");
details.appendChild(renderLayerManagerVisibilityOption(
geometryVisibilityLabel(record.layer),
visible,
checked => setManagedObjectVisible(record, checked, true)));
if (supportsManagedMeasurements(record)) {
managedMeasurementKinds(record).forEach(kind => {
const measurementsVisible = managedMeasurementsGloballyVisible(record.categoryKey)
&& !managedMeasurementHiddenSet(kind.key).has(record.id);
details.appendChild(renderLayerManagerVisibilityOption(
kind.label,
measurementsVisible,
checked => setManagedMeasurementKindVisible(record, kind.key, checked)));
});
}
const rows = managedObjectDetails(record.layer);
const info = document.createElement("div");
info.className = "layer-manager-info";
info.innerHTML = rows.length
? rows.map(value => `${escapeHtml(value)}`).join("")
: "Nessuna misura disponibile";
details.appendChild(info);
details.appendChild(renderManagedObjectTools(record));
return object;
}
function renderLayerSelectionToolbar() {
const selectedCount = lmeState.managerSelectedObjectIds.size;
const toolbar = document.createElement("section");
toolbar.className = "layer-selection-toolbar";
toolbar.innerHTML = `
${selectedCount} selezionati
`;
const search = toolbar.querySelector("input");
search.addEventListener("input", () => {
lmeState.managerSearchText = search.value;
refreshLayerManager();
requestAnimationFrame(() => document.querySelector(".layer-selection-toolbar input")?.focus());
});
toolbar.querySelectorAll("button").forEach(button => button.addEventListener("click", () => {
const records = [...lmeState.managerSelectedObjectIds]
.map(id => lmeState.managerRecords?.get(id)).filter(Boolean);
if (button.dataset.action === "clear") {
lmeState.managerSelectedObjectIds.clear();
refreshLayerManager();
} else if (!records.length) {
setInfo("Seleziona almeno un oggetto dalle sue opzioni.");
} else if (button.dataset.action === "duplicate") duplicateManagedRecords(records);
else if (button.dataset.action === "move") moveManagedRecords(records);
else if (button.dataset.action === "buffer") bufferManagedRecords(records);
else if (button.dataset.action === "union") unionManagedRecords(records);
else if (button.dataset.action === "split") splitManagedRecords(records);
else if (button.dataset.action === "delete") removeManagedRecords(records, "gli oggetti selezionati");
}));
return toolbar;
}
function renderManagedGroupTools(records) {
const tools = document.createElement("div");
tools.className = "layer-group-tools";
const properties = records[0]?.layer?.feature?.properties || {};
tools.innerHTML = `Azioni gruppo${escapeHtml(managedGroupTotals(records))}
`;
const inputs = tools.querySelectorAll("input");
inputs.forEach(input => input.addEventListener("change", () => {
applyManagedStyle(records, { color: inputs[0].value, weight: Number(inputs[1].value), opacity: Number(inputs[2].value) });
}));
tools.querySelector("[data-group-action='select']").addEventListener("click", () => {
records.forEach(record => lmeState.managerSelectedObjectIds.add(record.id)); refreshLayerManager();
});
tools.querySelector("[data-group-action='lock']").addEventListener("click", () => {
const lock = !records.every(record => Boolean(record.layer?.feature?.properties?._lmeLocked));
updateManagedProperties(records, properties => { properties._lmeLocked = lock; });
setInfo(lock ? "Gruppo bloccato." : "Gruppo sbloccato.");
});
tools.querySelector("[data-group-action='labels']").addEventListener("click", () => {
const visible = !records.every(record => Boolean(record.layer?.feature?.properties?._lmeLabelVisible));
updateManagedProperties(records, properties => { properties._lmeLabelVisible = visible; });
});
return tools;
}
function managedGroupTotals(records) {
let length = 0; let area = 0;
records.forEach(record => {
const layer = record.layer;
if (layer instanceof L.Circle) { area += Math.PI * layer.getRadius() ** 2; length += 2 * Math.PI * layer.getRadius(); }
else if (layer instanceof L.Polygon) polygonLatLngGroups(layer.getLatLngs()).forEach(polygon => {
area += Math.abs(polygon.reduce((sum, ring, index) => sum + (index ? -1 : 1) * Math.abs(L.GeometryUtil.geodesicArea(ring)), 0));
length += latLngPathLength(polygon[0] || [], true);
});
else if (layer instanceof L.Polyline) length += lineLatLngGroups(layer.getLatLngs()).reduce((sum, line) => sum + latLngPathLength(line, false), 0);
});
const values = []; if (area) values.push(`Area ${formatArea(area)}`); if (length) values.push(`Sviluppo ${formatDistance(length)}`);
return values.join(" | ") || `${records.length} oggetti`;
}
function renderManagedObjectTools(record) {
const properties = record.layer.feature?.properties || (record.layer.feature = { type: "Feature", properties: {} }).properties;
const selected = lmeState.managerSelectedObjectIds.has(record.id);
const locked = Boolean(properties._lmeLocked);
const tools = document.createElement("section");
tools.className = "layer-object-tools";
tools.innerHTML = `
${properties._lmePhoto ? `
` : ""}
`;
tools.querySelector("[data-field='selected']").addEventListener("change", event => {
if (event.target.checked) lmeState.managerSelectedObjectIds.add(record.id);
else lmeState.managerSelectedObjectIds.delete(record.id);
refreshLayerManager();
});
tools.querySelector("[data-field='locked']").addEventListener("change", event => updateManagedProperties([record], value => { value._lmeLocked = event.target.checked; }));
tools.querySelector("[data-field='label']").addEventListener("change", event => updateManagedProperties([record], value => { value._lmeLabelVisible = event.target.checked; }));
tools.querySelectorAll(".layer-style-controls input,.layer-style-controls select").forEach(input => input.addEventListener("change", () => applyManagedStyle([record], {
color: tools.querySelector("[data-field='color']").value,
fillColor: tools.querySelector("[data-field='fillColor']").value,
weight: Number(tools.querySelector("[data-field='weight']").value),
opacity: Number(tools.querySelector("[data-field='opacity']").value),
fillOpacity: Number(tools.querySelector("[data-field='fillOpacity']").value),
dash: tools.querySelector("[data-field='dash']").value
})));
tools.querySelector("[data-field='notes']").addEventListener("change", event => updateManagedProperties([record], value => { value._lmeNotes = event.target.value.trim(); }));
tools.querySelector("[data-field='photo']").addEventListener("change", async event => {
const file = event.target.files?.[0]; if (!file) return;
if (file.size > 2_500_000) { setInfo("Foto troppo grande: usa un file inferiore a 2,5 MB."); return; }
const dataUrl = await fileToDataUrl(file);
updateManagedProperties([record], value => { value._lmePhoto = dataUrl; });
});
tools.querySelector("[data-action='remove-photo']")?.addEventListener("click", () => updateManagedProperties([record], value => { delete value._lmePhoto; }));
tools.querySelector("[data-action='attributes']").addEventListener("click", () => editManagedAttributes(record));
tools.querySelector("[data-action='duplicate']").addEventListener("click", () => duplicateManagedRecords([record]));
tools.querySelector("[data-action='numeric']").addEventListener("click", () => numericEditManagedRecord(record));
tools.querySelector("[data-action='inspect']").addEventListener("click", () => showCoordinateInspectorForRecord(record));
tools.querySelector("[data-action='move']").addEventListener("click", () => moveManagedRecords([record]));
tools.querySelector("[data-action='buffer']").addEventListener("click", () => bufferManagedRecords([record]));
return tools;
}
function normalizedColor(value, fallback) {
return /^#[0-9a-f]{6}$/i.test(String(value || "")) ? value : fallback;
}
function editManagedAttributes(record) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
const properties = record.layer?.feature?.properties || {};
const reserved = new Set(["name", "Name", "label", "LABEL"]);
const current = Object.entries(properties)
.filter(([key, value]) => !key.startsWith("_lme") && !reserved.has(key) && ["string", "number", "boolean"].includes(typeof value))
.map(([key, value]) => `${key}=${value}`).join("\n");
const value = window.prompt("Attributi, uno per riga nel formato chiave=valore\nEsempi: foglio, particella, proprietario, qualita, data_rilievo, stato", current);
if (value === null) return;
const parsed = {};
value.split(/\r?\n/).forEach(line => {
const separator = line.indexOf("=");
if (separator < 1) return;
const key = line.slice(0, separator).trim().replace(/[^a-zA-Z0-9_ -]/g, "_").slice(0, 60);
const raw = line.slice(separator + 1).trim().slice(0, 500);
if (key) parsed[key] = raw;
});
updateManagedProperties([record], target => {
Object.keys(target).filter(key => !key.startsWith("_lme") && !reserved.has(key)).forEach(key => delete target[key]);
Object.assign(target, parsed);
});
finishMapChange("Aggiornamento attributi");
setInfo(`${Object.keys(parsed).length} attributi aggiornati.`);
}
function utmFromLatLng(latitude, longitude) {
const zone = Math.floor((longitude + 180) / 6) + 1;
const central = (zone - 1) * 6 - 180 + 3;
const a = 6378137; const eccSquared = 0.00669438; const k0 = 0.9996;
const latRad = latitude * Math.PI / 180; const lonRad = longitude * Math.PI / 180; const lonOriginRad = central * Math.PI / 180;
const eccPrimeSquared = eccSquared / (1 - eccSquared);
const n = a / Math.sqrt(1 - eccSquared * Math.sin(latRad) ** 2);
const t = Math.tan(latRad) ** 2; const c = eccPrimeSquared * Math.cos(latRad) ** 2;
const aa = Math.cos(latRad) * (lonRad - lonOriginRad);
const m = a * ((1 - eccSquared / 4 - 3 * eccSquared ** 2 / 64 - 5 * eccSquared ** 3 / 256) * latRad
- (3 * eccSquared / 8 + 3 * eccSquared ** 2 / 32 + 45 * eccSquared ** 3 / 1024) * Math.sin(2 * latRad)
+ (15 * eccSquared ** 2 / 256 + 45 * eccSquared ** 3 / 1024) * Math.sin(4 * latRad)
- (35 * eccSquared ** 3 / 3072) * Math.sin(6 * latRad));
const easting = k0 * n * (aa + (1 - t + c) * aa ** 3 / 6 + (5 - 18 * t + t ** 2 + 72 * c - 58 * eccPrimeSquared) * aa ** 5 / 120) + 500000;
let northing = k0 * (m + n * Math.tan(latRad) * (aa ** 2 / 2 + (5 - t + 9 * c + 4 * c ** 2) * aa ** 4 / 24
+ (61 - 58 * t + t ** 2 + 600 * c - 330 * eccPrimeSquared) * aa ** 6 / 720));
if (latitude < 0) northing += 10000000;
return { zone, band: latitude >= 0 ? "N" : "S", easting, northing };
}
function recordCenter(record) {
if (typeof record?.layer?.getBounds === "function" && record.layer.getBounds().isValid()) return record.layer.getBounds().getCenter();
return record?.layer?.getLatLng?.() || null;
}
function showCoordinateInspectorForRecord(record) {
const point = recordCenter(record);
if (!point) { setInfo("Coordinate non disponibili per questo oggetto."); return; }
showCoordinateDialog(point, managedObjectEditableName(record) || "Oggetto selezionato");
}
function showCoordinateInspector() {
const records = [...lmeState.managerSelectedObjectIds].map(id => lmeState.managerRecords?.get(id)).filter(Boolean);
const highlighted = lmeState.managerRecords?.get(lmeState.managerHighlightedObjectId);
const record = highlighted || records[0];
const point = record ? recordCenter(record) : lmeState.map.getCenter();
showCoordinateDialog(point, record ? managedObjectEditableName(record) || "Oggetto selezionato" : "Centro mappa");
}
function showCoordinateDialog(point, title) {
const utm = utmFromLatLng(point.lat, point.lng);
const text = `WGS84: ${point.lat.toFixed(7)}, ${point.lng.toFixed(7)}\nUTM ${utm.zone}${utm.band}: E ${utm.easting.toFixed(2)} m, N ${utm.northing.toFixed(2)} m`;
showAppDialog(title, `WGS84${point.lat.toFixed(7)}, ${point.lng.toFixed(7)}UTM zona ${utm.zone}${utm.band}E ${utm.easting.toFixed(2)} m
N ${utm.northing.toFixed(2)} m
`);
document.querySelector("[data-copy-coordinate]")?.addEventListener("click", async () => {
await navigator.clipboard?.writeText(text);
setInfo("Coordinate copiate.");
});
}
function showAppDialog(title, bodyHtml) {
document.getElementById("lmeAppDialog")?.remove();
const overlay = document.createElement("div");
overlay.id = "lmeAppDialog";
overlay.className = "lme-app-dialog";
overlay.innerHTML = ``;
overlay.querySelector("header button").addEventListener("click", () => overlay.remove());
overlay.addEventListener("click", event => { if (event.target === overlay) overlay.remove(); });
document.querySelector(".map-app")?.appendChild(overlay);
return overlay;
}
function showObjectTable() {
const records = layerManagerCategories().flatMap(category => managedRecordsForCategory(category));
const body = records.map((record, index) => {
const properties = record.layer?.feature?.properties || {};
const details = managedObjectDetails(record.layer);
return ` | ${escapeHtml(managedObjectLabel(record, index))} | ${escapeHtml(record.categoryKey)} | ${escapeHtml(properties.foglio || properties.FOGLIO || "")} | ${escapeHtml(properties.particella || properties.PARTICELLA || properties.LABEL || "")} | ${escapeHtml(details.slice(0, 2).join(" | "))} |
`;
}).join("");
const dialog = showAppDialog("Elenco oggetti e particelle", ` | Nome | Gruppo | Foglio | Particella | Misure |
${body || "| Nessun oggetto |
"}
`);
const byId = new Map(records.map(record => [record.id, record]));
dialog.querySelector("input").addEventListener("input", event => {
const query = event.target.value.toLocaleLowerCase("it");
dialog.querySelectorAll("tbody tr").forEach(row => { row.hidden = query && !row.textContent.toLocaleLowerCase("it").includes(query); });
});
dialog.querySelectorAll("[data-locate]").forEach(button => button.addEventListener("click", () => {
const record = byId.get(button.dataset.locate); if (!record) return;
dialog.remove(); highlightManagedObject(record);
}));
return records.length;
}
function showAuditLog() {
const rows = [...lmeState.auditLog].reverse().map(item => `| ${escapeHtml(new Date(item.at).toLocaleString("it-IT"))} | ${escapeHtml(item.action || "Modifica mappa")} |
`).join("");
showAppDialog("Registro operazioni", `| Data e ora | Operazione |
${rows || "| Nessuna operazione registrata |
"}
`);
return lmeState.auditLog.length;
}
function updateManagedProperties(records, change) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
rememberMapChange();
records.forEach(record => {
record.layer.feature ||= { type: "Feature", properties: {} };
record.layer.feature.properties ||= {};
change(record.layer.feature.properties);
applyStyleFromProperties(record.layer);
updateStoredFeaturePopup(record.layer);
});
refreshPermanentLabels();
refreshLayerManager();
finishMapChange();
}
function applyManagedStyle(records, style) {
updateManagedProperties(records, properties => {
properties._lmeColor = style.color;
properties._lmeFillColor = style.fillColor || style.color;
properties._lmeWeight = style.weight;
properties._lmeOpacity = style.opacity;
if (style.fillOpacity !== undefined) properties._lmeFillOpacity = style.fillOpacity;
if (style.dash !== undefined) properties._lmeDash = style.dash;
});
}
function applyStyleFromProperties(layer) {
const properties = layer?.feature?.properties || {};
if (typeof layer?.setStyle === "function") {
layer.setStyle({
color: properties._lmeColor || layer.options.color,
fillColor: properties._lmeFillColor || layer.options.fillColor,
weight: Number(properties._lmeWeight) || layer.options.weight,
opacity: Number.isFinite(Number(properties._lmeOpacity)) ? Number(properties._lmeOpacity) : layer.options.opacity,
fillOpacity: Number.isFinite(Number(properties._lmeFillOpacity)) ? Number(properties._lmeFillOpacity) : layer.options.fillOpacity,
dashArray: properties._lmeDash || null
});
}
}
function refreshPermanentLabels() {
lmeState.permanentLabels?.clearLayers();
layerManagerCategories().forEach(category => managedRecordsForCategory(category).forEach((record, index) => {
const properties = record.layer?.feature?.properties || {};
if (!properties._lmeLabelVisible || lmeState.managerHiddenObjectIds.has(record.id)) return;
const center = layerLabelPosition(record.layer); if (!center) return;
L.marker(center, { pane: "tooltipPane", interactive: false, icon: L.divIcon({ className: "lme-permanent-label", html: `${escapeHtml(managedObjectLabel(record, index))}`, iconSize: null }) }).addTo(lmeState.permanentLabels);
}));
}
function layerLabelPosition(layer) {
if (typeof layer?.getBounds === "function" && layer.getBounds().isValid()) return layer.getBounds().getCenter();
if (typeof layer?.getLatLng === "function") return layer.getLatLng();
return null;
}
function fileToDataUrl(file) {
return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); });
}
function addDrawingFeature(feature, select = false) {
let added = null;
L.geoJSON(feature, {
pane: "drawingsPane",
style: storedFeatureStyle,
pointToLayer: (item, latlng) => item?.properties?._lmeShape === "circle" && Number(item.properties._lmeRadius) > 0
? L.circle(latlng, { pane: "drawingsPane", renderer: lmeState.drawingsRenderer, radius: Number(item.properties._lmeRadius) })
: L.marker(latlng, { pane: "drawingsPane", draggable: false }),
onEachFeature: (item, layer) => {
annotateDrawingLayer(layer, item?.properties?._lmeShape);
applyStyleFromProperties(layer);
updateStoredFeaturePopup(layer);
lmeState.drawings.addLayer(layer);
added = layer;
}
});
if (select && added) selectDrawingLayer(added);
return added;
}
function storedFeatureStyle(feature) {
const properties = feature?.properties || {};
return {
renderer: lmeState.drawingsRenderer,
color: properties._lmeColor || "#f97316",
fillColor: properties._lmeFillColor || "#fb923c",
weight: Number(properties._lmeWeight) || 3,
opacity: Number.isFinite(Number(properties._lmeOpacity)) ? Number(properties._lmeOpacity) : 1,
fillOpacity: Number.isFinite(Number(properties._lmeFillOpacity)) ? Number(properties._lmeFillOpacity) : 0.2,
dashArray: properties._lmeDash || null
};
}
function duplicateManagedRecords(records) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) { setInfo("Sblocca gli oggetti da duplicare."); return; }
rememberMapChange();
records.forEach((record, index) => {
const feature = record.layer.toGeoJSON();
feature.properties = { ...(feature.properties || {}), name: `${managedObjectLabel(record, index)} - copia`, _lmeManagerId: undefined };
if (record.layer instanceof L.Circle) {
const point = turf.transformTranslate(feature, 1.5, 45, { units: "meters" });
point.properties._lmeShape = "circle";
point.properties._lmeRadius = record.layer.getRadius();
addDrawingFeature(point);
} else {
addDrawingFeature(turf.transformTranslate(feature, 1.5, 45, { units: "meters" }));
}
});
refreshDrawingMeasurements(); refreshLayerManager(); finishMapChange();
setInfo(`${records.length} ${records.length === 1 ? "oggetto duplicato" : "oggetti duplicati"}.`);
}
function moveManagedRecords(records) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) { setInfo("Sblocca gli oggetti da spostare."); return; }
const north = Number(window.prompt("Spostamento verso nord in metri (negativo verso sud)", "0"));
if (!Number.isFinite(north)) return;
const east = Number(window.prompt("Spostamento verso est in metri (negativo verso ovest)", "0"));
if (!Number.isFinite(east) || (!north && !east)) return;
const distance = Math.hypot(north, east);
const bearing = Math.atan2(east, north) * 180 / Math.PI;
rememberMapChange();
records.forEach(record => {
const moved = turf.transformTranslate(record.layer.toGeoJSON(), distance, bearing, { units: "meters" });
replaceManagedLayer(record, moved);
});
finishManagedGeometryChange(`Spostati ${records.length} oggetti.`);
}
function bufferManagedRecords(records) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
const meters = Number(window.prompt("Distanza buffer in metri", "5"));
if (!Number.isFinite(meters) || meters <= 0) return;
rememberMapChange();
let count = 0;
records.forEach((record, index) => {
const buffered = turf.buffer(record.layer.toGeoJSON(), meters, { units: "meters" });
if (!buffered) return;
buffered.properties = { ...(record.layer.feature?.properties || {}), name: `Buffer ${meters} m - ${managedObjectLabel(record, index)}`, _lmeShape: "polygon", _lmeManagerId: undefined };
addDrawingFeature(buffered); count++;
});
finishManagedGeometryChange(`Creati ${count} buffer da ${formatDistance(meters)}.`);
}
function unionManagedRecords(records) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
const polygons = records.filter(record => record.layer instanceof L.Polygon).map(record => record.layer.toGeoJSON());
if (polygons.length < 2) { setInfo("Per unire seleziona almeno due aree o poligoni."); return; }
try {
const merged = turf.union(turf.featureCollection(polygons));
if (!merged) throw new Error("le aree non producono un risultato");
rememberMapChange();
merged.properties = { ...(polygons[0].properties || {}), name: "Unione aree", _lmeShape: "polygon", _lmeManagerId: undefined };
addDrawingFeature(merged, true);
polygons.forEach((_feature, index) => removeRecordWithoutPrompt(records.filter(record => record.layer instanceof L.Polygon)[index]));
lmeState.managerSelectedObjectIds.clear();
finishManagedGeometryChange("Aree unite in un nuovo oggetto.");
} catch (error) { setInfo(`Unione non riuscita: ${error.message}`); }
}
function splitManagedRecords(records) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
const polygonRecord = records.find(record => record.layer instanceof L.Polygon);
const lineRecord = records.find(record => record.layer instanceof L.Polyline && !(record.layer instanceof L.Polygon));
if (!polygonRecord || !lineRecord) { setInfo("Per dividere seleziona un poligono e una linea di taglio."); return; }
try {
const cutter = turf.buffer(lineRecord.layer.toGeoJSON(), 0.02, { units: "meters" });
const split = turf.difference(turf.featureCollection([polygonRecord.layer.toGeoJSON(), cutter]));
const parts = split ? turf.flatten(split).features : [];
if (parts.length < 2) throw new Error("la linea deve attraversare completamente il poligono");
rememberMapChange();
parts.forEach((part, index) => {
part.properties = { ...(polygonRecord.layer.feature?.properties || {}), name: `${managedObjectLabel(polygonRecord, 0)} - parte ${index + 1}`, _lmeManagerId: undefined };
addDrawingFeature(part);
});
removeRecordWithoutPrompt(polygonRecord);
lmeState.managerSelectedObjectIds.clear();
finishManagedGeometryChange(`Poligono diviso in ${parts.length} parti.`);
} catch (error) { setInfo(`Divisione non riuscita: ${error.message}`); }
}
function selectedManagedRecords() {
const current = lmeState.managerRecords || new Map();
return [...lmeState.managerSelectedObjectIds].map(id => current.get(id)).filter(Boolean);
}
function createHoleFromSelected() {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
const polygons = selectedManagedRecords().filter(record => record.layer instanceof L.Polygon);
if (polygons.length !== 2) { setInfo("Seleziona esattamente due poligoni: prima l'area esterna e poi l'area da escludere."); return false; }
try {
const ordered = [...polygons].sort((left, right) => turf.area(right.layer.toGeoJSON()) - turf.area(left.layer.toGeoJSON()));
const outer = ordered[0].layer.toGeoJSON();
const inner = ordered[1].layer.toGeoJSON();
if (!turf.booleanWithin(inner, outer)) throw new Error("il poligono interno deve essere completamente contenuto nell'area esterna");
const result = turf.difference(turf.featureCollection([outer, inner]));
if (!result) throw new Error("nessun risultato geometrico");
rememberMapChange();
result.properties = { ...(outer.properties || {}), name: `${managedObjectLabel(ordered[0], 0)} con area esclusa`, _lmeManagerId: undefined };
addDrawingFeature(result, true);
ordered.forEach(removeRecordWithoutPrompt);
lmeState.managerSelectedObjectIds.clear();
finishManagedGeometryChange("Poligono con foro creato correttamente.");
return true;
} catch (error) { setInfo(`Creazione foro non riuscita: ${error.message}`); return false; }
}
function validateAndRepairSelected() {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return "Progetto in sola lettura"; }
const records = selectedManagedRecords();
if (!records.length) { setInfo("Seleziona gli oggetti da controllare."); return "Nessuna selezione"; }
let valid = 0; let repaired = 0; let rejected = 0;
rememberMapChange();
records.forEach((record, index) => {
try {
let feature = turf.cleanCoords(record.layer.toGeoJSON(), { mutate: false });
if (feature.geometry?.type === "Polygon" && typeof turf.booleanValid === "function" && !turf.booleanValid(feature)) {
const parts = turf.unkinkPolygon(feature).features;
if (!parts.length) throw new Error("geometria non riparabile");
removeRecordWithoutPrompt(record);
parts.forEach((part, partIndex) => {
part.properties = { ...(feature.properties || {}), name: `${managedObjectLabel(record, index)} - riparato ${partIndex + 1}`, _lmeManagerId: undefined };
addDrawingFeature(part);
});
repaired++;
} else {
if (JSON.stringify(feature.geometry) !== JSON.stringify(record.layer.toGeoJSON().geometry)) {
replaceManagedLayer(record, feature); repaired++;
} else valid++;
}
} catch { rejected++; }
});
lmeState.managerSelectedObjectIds.clear();
finishManagedGeometryChange(`Controllo geometrie: ${valid} valide, ${repaired} riparate, ${rejected} non riparabili.`);
return `${valid} valide, ${repaired} riparate, ${rejected} non riparabili`;
}
function compareSelectedGeometries() {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
const polygons = selectedManagedRecords().filter(record => record.layer instanceof L.Polygon);
if (polygons.length !== 2) { setInfo("Seleziona esattamente due poligoni da confrontare."); return false; }
try {
const first = polygons[0].layer.toGeoJSON(); const second = polygons[1].layer.toGeoJSON();
const onlyFirst = turf.difference(turf.featureCollection([first, second]));
const onlySecond = turf.difference(turf.featureCollection([second, first]));
const overlap = turf.intersect(turf.featureCollection([first, second]));
rememberMapChange();
const addResult = (feature, name, color) => {
if (!feature) return;
feature.properties = { name, _lmeColor: color, _lmeFillColor: color, _lmeFillOpacity: 0.38, _lmeManagerId: undefined };
addDrawingFeature(feature);
};
addResult(onlyFirst, `Solo ${managedObjectLabel(polygons[0], 0)}`, "#ef4444");
addResult(onlySecond, `Solo ${managedObjectLabel(polygons[1], 1)}`, "#3b82f6");
addResult(overlap, "Sovrapposizione", "#16a34a");
const firstArea = onlyFirst ? turf.area(onlyFirst) : 0; const secondArea = onlySecond ? turf.area(onlySecond) : 0;
finishManagedGeometryChange(`Confronto creato: eccedenza ${formatArea(firstArea)}, mancanza ${formatArea(secondArea)}.`);
return true;
} catch (error) { setInfo(`Confronto non riuscito: ${error.message}`); return false; }
}
function numericEditManagedRecord(record) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (record.layer?.feature?.properties?._lmeLocked) { setInfo("Sblocca prima l'oggetto."); return; }
if (record.layer instanceof L.Circle) {
const center = record.layer.getLatLng();
const value = window.prompt("Latitudine, longitudine, raggio in metri", `${center.lat.toFixed(7)}, ${center.lng.toFixed(7)}, ${record.layer.getRadius().toFixed(2)}`);
if (value === null) return;
const parts = value.split(/[;, ]+/).map(Number).filter(Number.isFinite);
if (parts.length < 3) { setInfo("Inserisci latitudine, longitudine e raggio validi."); return; }
rememberMapChange(); record.layer.setLatLng([parts[0], parts[1]]); record.layer.setRadius(parts[2]); annotateDrawingLayer(record.layer, "circle");
} else if (typeof record.layer?.getLatLng === "function") {
const point = record.layer.getLatLng();
const value = window.prompt("Latitudine, longitudine", `${point.lat.toFixed(7)}, ${point.lng.toFixed(7)}`);
if (value === null) return;
const parts = value.split(/[;, ]+/).map(Number).filter(Number.isFinite);
if (parts.length < 2) { setInfo("Inserisci coordinate valide."); return; }
rememberMapChange(); record.layer.setLatLng([parts[0], parts[1]]);
} else {
const lines = lineLatLngGroups(record.layer.getLatLngs());
const current = lines[0]?.map(point => `${point.lat.toFixed(7)},${point.lng.toFixed(7)}`).join("; ") || "";
const value = window.prompt("Vertici: coppie latitudine,longitudine separate da punto e virgola", current);
if (value === null) return;
const points = value.split(";").map(pair => pair.split(",").map(Number)).filter(pair => pair.length === 2 && pair.every(Number.isFinite));
if (points.length < 2) { setInfo("Servono almeno due vertici validi."); return; }
rememberMapChange(); record.layer.setLatLngs(points.map(point => L.latLng(point[0], point[1])));
}
finishManagedGeometryChange("Coordinate aggiornate.");
}
function replaceManagedLayer(record, feature) {
const parent = record.parent?.hasLayer?.(record.layer) ? record.parent : lmeState.drawings;
parent.removeLayer?.(record.layer);
clearManagedObjectState(record.id);
feature.properties = { ...(record.layer.feature?.properties || {}), ...(feature.properties || {}), _lmeManagerId: undefined };
addDrawingFeature(feature);
}
function removeRecordWithoutPrompt(record) {
record.parent?.removeLayer?.(record.layer); clearManagedObjectState(record.id);
}
function finishManagedGeometryChange(message) {
refreshImportedMeasurements(); refreshDrawingMeasurements(); refreshPermanentLabels(); updateGpsInsideState(); refreshLayerManager(); finishMapChange(); setInfo(message);
}
function rememberMapChange() {
if (lmeState.historyBusy || !lmeState.map) return;
const snapshot = createProjectSnapshot(true);
if (lmeState.undoStack.at(-1) !== snapshot) lmeState.undoStack.push(snapshot);
if (lmeState.undoStack.length > 30) lmeState.undoStack.shift();
lmeState.redoStack = [];
}
function finishMapChange() {
if (lmeState.historyBusy || !lmeState.map) return;
const action = arguments[0] || "Modifica mappa";
lmeState.isDirty = true;
lmeState.auditLog.push({ at: new Date().toISOString(), action });
if (lmeState.auditLog.length > 250) lmeState.auditLog.shift();
if (lmeState.autoSaveEnabled) {
try { localStorage.setItem("lmeAutosaveV3", createProjectSnapshot(true)); } catch { }
}
window.clearTimeout(lmeState.changeNotifyTimer);
lmeState.changeNotifyTimer = window.setTimeout(() => {
void lmeState.dotNetReference?.invokeMethodAsync("OnMapChanged", action);
}, 450);
}
function getHistoryState() {
return { canUndo: lmeState.undoStack.length > 0, canRedo: lmeState.redoStack.length > 0, dirty: lmeState.isDirty };
}
function markProjectSaved() {
lmeState.isDirty = false;
void lmeState.dotNetReference?.invokeMethodAsync("OnProjectSaved");
}
function confirmDiscardChanges(actionLabel) {
return !lmeState.isDirty || window.confirm(`Ci sono modifiche non salvate. Continuare e ${actionLabel || "abbandonarle"}?`);
}
function setProjectReadOnly(enabled) {
lmeState.readOnly = Boolean(enabled);
document.querySelector(".map-app")?.classList.toggle("project-readonly", lmeState.readOnly);
if (lmeState.readOnly) setDrawToolsVisible(false);
refreshLayerManager();
setInfo(lmeState.readOnly ? "Progetto aperto in sola lettura. Duplicalo per modificarlo." : "Progetto modificabile.");
}
function undoMapChange() {
if (!lmeState.undoStack.length) { setInfo("Nessuna operazione da annullare."); return; }
lmeState.redoStack.push(createProjectSnapshot(true));
restoreHistorySnapshot(lmeState.undoStack.pop(), "Operazione annullata.");
lmeState.isDirty = true;
}
function redoMapChange() {
if (!lmeState.redoStack.length) { setInfo("Nessuna operazione da ripristinare."); return; }
lmeState.undoStack.push(createProjectSnapshot(true));
restoreHistorySnapshot(lmeState.redoStack.pop(), "Operazione ripristinata.");
lmeState.isDirty = true;
}
function restoreLastTrashItem() {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
const entry = lmeState.trash.pop();
if (!entry) { setInfo("Il cestino e' vuoto."); return false; }
rememberMapChange();
entry.items.forEach(item => {
if (item.categoryKey === "imported") addImportedFeatures({ type: "FeatureCollection", features: [item.feature] });
else if (item.categoryKey === "importedSheets") addImportedSheetFeatures({ type: "FeatureCollection", features: [item.feature] });
else addDrawingFeature(item.feature);
});
refreshLayerManager();
finishManagedGeometryChange(`Ripristinati ${entry.items.length} oggetti dal cestino.`);
return true;
}
function emptyTrash() {
if (!lmeState.trash.length) { setInfo("Il cestino e' gia vuoto."); return 0; }
if (!window.confirm(`Eliminare definitivamente ${lmeState.trash.reduce((sum, entry) => sum + entry.items.length, 0)} oggetti dal cestino?`)) return lmeState.trash.length;
lmeState.trash = [];
setInfo("Cestino svuotato.");
return 0;
}
function restoreHistorySnapshot(snapshot, message) {
lmeState.historyBusy = true;
try { loadProjectSnapshot(snapshot, "cronologia"); setInfo(message); } finally { lmeState.historyBusy = false; }
}
function restoreAutosave() {
const snapshot = localStorage.getItem("lmeAutosaveV3");
if (!snapshot) { setInfo("Nessun recupero automatico disponibile."); return false; }
rememberMapChange(); loadProjectSnapshot(snapshot, "recupero automatico"); return true;
}
function setAdvancedOptions(options) {
const value = options || {};
lmeState.snapEnabled = value.snapEnabled !== false;
lmeState.snapDistancePixels = Math.max(4, Math.min(40, Number(value.snapDistancePixels) || 14));
lmeState.autoSaveEnabled = value.autoSaveEnabled !== false;
lmeState.gpsProximityEnabled = value.gpsProximityEnabled !== false;
lmeState.gpsBoundaryAlertEnabled = Boolean(value.gpsBoundaryAlertEnabled);
}
function compareProjectSnapshots(currentRaw, previousRaw) {
const current = typeof currentRaw === "string" ? JSON.parse(currentRaw) : currentRaw;
const previous = typeof previousRaw === "string" ? JSON.parse(previousRaw) : previousRaw;
const collect = snapshot => ["drawings", "imported", "importedSheets", "selectedParcels"]
.flatMap(key => snapshot?.[key]?.features || []);
const keyFor = (feature, index) => feature?.properties?._lmeManagerId
|| `${feature?.properties?.name || feature?.properties?.Name || "object"}:${index}`;
const currentMap = new Map(collect(current).map((feature, index) => [keyFor(feature, index), JSON.stringify(feature)]));
const previousMap = new Map(collect(previous).map((feature, index) => [keyFor(feature, index), JSON.stringify(feature)]));
let added = 0; let removed = 0; let changed = 0;
currentMap.forEach((value, key) => { if (!previousMap.has(key)) added++; else if (previousMap.get(key) !== value) changed++; });
previousMap.forEach((_value, key) => { if (!currentMap.has(key)) removed++; });
const message = `Confronto revisione: ${added} aggiunti, ${removed} rimossi, ${changed} modificati.`;
setInfo(message);
return message;
}
function renderLayerManagerVisibilityOption(label, checked, onChange) {
const option = document.createElement("label");
option.className = "layer-manager-suboption";
option.innerHTML = `${escapeHtml(label)}`;
const checkbox = option.querySelector("input");
checkbox.addEventListener("click", event => event.stopPropagation());
checkbox.addEventListener("change", () => onChange(checkbox.checked));
return option;
}
function geometryVisibilityLabel(layer) {
if (layer instanceof L.Polygon || layer instanceof L.Circle) {
return "Area/geometria sulla mappa";
}
if (layer instanceof L.Polyline) {
return "Linea sulla mappa";
}
return "Punto sulla mappa";
}
function supportsManagedMeasurements(record) {
return ["imported", "drawings"].includes(record.categoryKey)
&& (record.layer instanceof L.Circle
|| record.layer instanceof L.Polygon
|| record.layer instanceof L.Polyline);
}
function managedMeasurementsGloballyVisible(categoryKey) {
return categoryKey === "imported"
? lmeState.showImportedMeasurements
: lmeState.showDrawingMeasurements;
}
function managedMeasurementKinds(record) {
if (record.layer instanceof L.Polygon) {
return [
{ key: "summary", label: "Superficie e perimetro sulla mappa" },
{ key: "sides", label: "Misure dei lati sulla mappa" }
];
}
if (record.layer instanceof L.Circle) {
return [{ key: "summary", label: "Superficie, raggio e circonferenza sulla mappa" }];
}
return [
{ key: "summary", label: "Lunghezza totale sulla mappa" },
{ key: "sides", label: "Misure dei tratti sulla mappa" }
];
}
function managedMeasurementHiddenSet(kind) {
return kind === "sides"
? lmeState.managerHiddenMeasurementSideIds
: lmeState.managerHiddenMeasurementSummaryIds;
}
function setManagedMeasurementKindVisible(record, kind, visible) {
const hiddenSet = managedMeasurementHiddenSet(kind);
if (visible) {
if (!managedMeasurementsGloballyVisible(record.categoryKey)) {
lmeState.managerRecords.forEach(candidate => {
if (candidate.categoryKey === record.categoryKey
&& supportsManagedMeasurements(candidate)) {
lmeState.managerHiddenMeasurementSummaryIds.add(candidate.id);
lmeState.managerHiddenMeasurementSideIds.add(candidate.id);
}
});
if (record.categoryKey === "imported") {
lmeState.showImportedMeasurements = true;
} else {
lmeState.showDrawingMeasurements = true;
}
void lmeState.dotNetReference?.invokeMethodAsync(
"SetMeasurementsFromLayerManager",
record.categoryKey,
true);
}
hiddenSet.delete(record.id);
} else {
hiddenSet.add(record.id);
}
refreshImportedMeasurements();
refreshDrawingMeasurements();
refreshLayerManager();
}
function captureLayerManagerExpansion(container) {
container.querySelectorAll("details[data-expansion-key]").forEach(row => {
if (row.open) {
lmeState.managerExpandedRows.add(row.dataset.expansionKey);
} else {
lmeState.managerExpandedRows.delete(row.dataset.expansionKey);
}
});
}
function bindLayerManagerExpansion(row, key) {
row.dataset.expansionKey = key;
row.open = lmeState.managerExpandedRows.has(key);
row.addEventListener("toggle", () => {
if (row.open) {
lmeState.managerExpandedRows.add(key);
} else {
lmeState.managerExpandedRows.delete(key);
}
});
}
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();
refreshPermanentLabels();
if (refresh) {
refreshLayerManager();
}
}
function managedCategoryEnabled(categoryKey) {
if (lmeState.managerHiddenCategories.has(categoryKey)) {
return false;
}
if (categoryKey === "gpsPosition") {
return lmeState.gpsPositionVisible;
}
if (categoryKey === "imported") {
return lmeState.importedVisible;
}
if (categoryKey === "importedSheets") {
return lmeState.importedSheetsVisible;
}
if (categoryKey === "selectedParcels") {
return lmeState.selectedParcelsVisible;
}
return true;
}
function highlightManagedObject(record) {
if (lmeState.managerHighlightedObjectId === record.id) {
clearManagerHighlight();
return;
}
clearManagerHighlight();
lmeState.managerHighlightedObjectId = record.id;
lmeState.managerHighlightedCategoryKey = record.categoryKey;
document.querySelectorAll(".layer-manager-object").forEach(row => {
row.classList.toggle("layer-manager-object-selected", row.dataset.objectId === record.id);
});
setSelectedDrawing(record.categoryKey === "drawings" ? record.layer : null);
renderManagerHighlight([record]);
fitLayer(record.layer);
}
function drawingEditToolbar() {
return lmeState.drawControl?._toolbars?.[L.EditToolbar.TYPE] || null;
}
function drawingEditActive() {
return Boolean(drawingEditToolbar()?.enabled?.());
}
function updateDrawingSelectionActions() {
const hasSelection = Boolean(lmeState.drawingSelection?.getLayers?.().length);
const hasDrawings = Boolean(lmeState.drawings?.getLayers?.().length);
lmeState.drawingDeleteButton?.classList.toggle("leaflet-disabled", !hasDrawings);
if (lmeState.drawingDeleteButton) {
lmeState.drawingDeleteButton.setAttribute("aria-disabled", String(!hasDrawings));
}
lmeState.drawingScopeMenu?.querySelector("[data-drawing-scope='selected']")
?.setAttribute("aria-disabled", String(!hasSelection));
}
function setSelectedDrawing(layer) {
if (!lmeState.drawingSelection || drawingEditActive()) {
return;
}
lmeState.drawingSelection.clearLayers();
if (layer) {
lmeState.drawingSelection.addLayer(layer);
}
updateDrawingSelectionActions();
}
function selectDrawingLayer(layer) {
if (drawingEditActive()) {
return;
}
const record = {
id: managedLayerId(layer, "drawings"),
categoryKey: "drawings",
layer,
parent: lmeState.drawings,
source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente")
};
clearManagerHighlight();
lmeState.managerHighlightedObjectId = record.id;
lmeState.managerHighlightedCategoryKey = "drawings";
document.querySelectorAll(".layer-manager-object").forEach(row => {
row.classList.toggle("layer-manager-object-selected", row.dataset.objectId === record.id);
});
setSelectedDrawing(layer);
renderManagerHighlight([record]);
setInfo(`Selezione: ${managedObjectLabel(record, 0)}. Usa la matita per modificare o il cestino per eliminare.`);
}
function bindManagedParentHighlight(row, key, records) {
const summary = row.querySelector(":scope > summary");
row.dataset.highlightKey = key;
row.classList.toggle("layer-manager-parent-selected", lmeState.managerHighlightedParentKey === key);
if (lmeState.managerHighlightedParentKey === key) {
lmeState.managerHighlightedParentRecords = records;
}
row.querySelector(":scope > summary > .layer-manager-title").addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
row.open = true;
highlightManagedParent(row, key, records);
});
summary.addEventListener("pointerenter", () => {
lmeState.managerHoveredParentKey = key;
lmeState.managerHoveredParentRecords = records;
row.classList.add("layer-manager-parent-highlighted");
renderManagerHighlight(records);
});
summary.addEventListener("pointerleave", () => {
if (lmeState.managerHoveredParentKey !== key) {
return;
}
lmeState.managerHoveredParentKey = null;
lmeState.managerHoveredParentRecords = null;
row.classList.remove("layer-manager-parent-highlighted");
renderSelectedManagerHighlight();
});
}
function highlightManagedParent(row, key, records) {
if (lmeState.managerHighlightedParentKey === key) {
clearManagerHighlight();
return;
}
clearManagerHighlight();
lmeState.managerHighlightedParentKey = key;
lmeState.managerHighlightedParentRecords = records;
lmeState.managerHighlightedCategoryKey = records[0]?.categoryKey || null;
setSelectedDrawing(null);
row.classList.add("layer-manager-parent-selected");
renderManagerHighlight(records);
}
function renderSelectedManagerHighlight() {
if (lmeState.managerHighlightedParentRecords?.length) {
renderManagerHighlight(lmeState.managerHighlightedParentRecords);
return;
}
const selected = lmeState.managerRecords.get(lmeState.managerHighlightedObjectId);
renderManagerHighlight(selected ? [selected] : []);
}
function renderManagerHighlight(records) {
lmeState.managerHighlight?.clearLayers();
const features = records.flatMap(record => {
const geojson = record.layer?.toGeoJSON?.();
if (!geojson) {
return [];
}
return geojson.type === "FeatureCollection" ? geojson.features : [geojson];
});
if (!features.length) {
return;
}
L.geoJSON({ type: "FeatureCollection", features }, {
pane: "managerHighlightPane",
interactive: false,
style: { color: "#ff00d4", weight: 8, opacity: 1, fillColor: "#00fff0", fillOpacity: 0.16 },
pointToLayer: (feature, latlng) => L.circleMarker(latlng, {
pane: "managerHighlightPane",
radius: 12,
color: "#ff00d4",
weight: 5,
fillColor: "#00fff0",
fillOpacity: 0.9,
interactive: false
})
}).addTo(lmeState.managerHighlight);
}
function clearManagerHighlight() {
lmeState.managerHighlight?.clearLayers();
lmeState.managerHighlightedObjectId = null;
lmeState.managerHighlightedCategoryKey = null;
lmeState.managerHighlightedParentKey = null;
lmeState.managerHighlightedParentRecords = null;
lmeState.managerHoveredParentKey = null;
lmeState.managerHoveredParentRecords = null;
setSelectedDrawing(null);
document.querySelectorAll(".layer-manager-object-selected").forEach(row => {
row.classList.remove("layer-manager-object-selected");
});
document.querySelectorAll(".layer-manager-parent-highlighted").forEach(row => {
row.classList.remove("layer-manager-parent-highlighted");
});
document.querySelectorAll(".layer-manager-parent-selected").forEach(row => {
row.classList.remove("layer-manager-parent-selected");
});
}
function layerManagerMoveButtons(label) {
return ``;
}
function bindLayerManagerDrag(container) {
container.querySelectorAll(".layer-drag-handle").forEach(handle => {
handle.addEventListener("pointerdown", event => {
event.preventDefault();
event.stopPropagation();
const row = handle.closest("[data-drag-type]");
const list = row.parentElement;
row.classList.add("layer-manager-dragging");
handle.setPointerCapture?.(event.pointerId);
const move = moveEvent => {
moveEvent.preventDefault();
const target = document.elementFromPoint(moveEvent.clientX, moveEvent.clientY)?.closest(`[data-drag-type="${row.dataset.dragType}"]`);
if (!target || target === row || target.parentElement !== list) {
return;
}
const targetRect = target.getBoundingClientRect();
list.insertBefore(row, moveEvent.clientY < targetRect.top + targetRect.height / 2 ? target : target.nextSibling);
};
const end = () => {
row.classList.remove("layer-manager-dragging");
handle.releasePointerCapture?.(event.pointerId);
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", end);
document.removeEventListener("pointercancel", end);
applyLayerManagerDomOrder(container);
};
document.addEventListener("pointermove", move, { passive: false });
document.addEventListener("pointerup", end);
document.addEventListener("pointercancel", end);
});
});
}
function bindLayerManagerMoveButtons(container) {
container.querySelectorAll(".layer-move-up, .layer-move-down").forEach(button => {
button.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
const row = button.closest("[data-drag-type]");
const sibling = button.classList.contains("layer-move-up")
? row.previousElementSibling
: row.nextElementSibling;
if (!sibling || sibling.dataset.dragType !== row.dataset.dragType) {
return;
}
if (button.classList.contains("layer-move-up")) {
row.parentElement.insertBefore(row, sibling);
} else {
row.parentElement.insertBefore(sibling, row);
}
applyLayerManagerDomOrder(container);
});
});
}
function applyLayerManagerDomOrder(container) {
const categoryKeys = [...container.children].map(row => row.dataset.categoryKey).filter(Boolean);
setLayerOrder(categoryKeys);
container.querySelectorAll(":scope > .layer-manager-group").forEach(group => {
const objectIds = [...group.querySelectorAll(".layer-manager-object")].map(row => row.dataset.objectId).filter(Boolean);
lmeState.managerObjectOrder[group.dataset.categoryKey] = objectIds;
});
applyManagedVisualOrder(container);
saveLayerManagerState();
}
function applyManagedVisualOrder(container) {
container.querySelectorAll(":scope > .layer-manager-group").forEach(group => {
const objectIds = [...group.querySelectorAll(".layer-manager-object")].map(row => row.dataset.objectId).filter(Boolean);
[...objectIds].reverse().forEach(id => lmeState.managerRecords.get(id)?.layer?.bringToFront?.());
objectIds.forEach((id, index) => lmeState.managerRecords.get(id)?.layer?.setZIndexOffset?.(1000 - index));
});
}
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: (feature, layer) => { bindStoredFeaturePopup(feature, layer); applyStyleFromProperties(layer); bindManagedMapSelection(layer, "imported", lmeState.imported); }
}).addTo(lmeState.imported);
layer.lmeManagerLabel = managedGeoJsonLabel(geojson, "Dati importati");
return layer;
}
function addImportedSheetFeatures(geojson) {
const layer = L.geoJSON(geojson || emptyFeatureCollection(), {
pane: "importedSheetPane",
style: { color: "#f59e0b", weight: 4, opacity: 1, fillOpacity: 0.03, dashArray: "10 6" },
pointToLayer: (feature, latlng) => L.circleMarker(latlng, {
pane: "importedSheetPane",
radius: 7,
color: "#f59e0b",
fillColor: "#ffffff",
fillOpacity: 0.85
}),
onEachFeature: (feature, layer) => { bindStoredFeaturePopup(feature, layer); applyStyleFromProperties(layer); bindManagedMapSelection(layer, "importedSheets", lmeState.importedSheets); }
}).addTo(lmeState.importedSheets);
layer.lmeManagerLabel = managedGeoJsonLabel(geojson, "Cornice o foglio");
return layer;
}
function applyImportedSheetVisibility() {
if (!lmeState.map || !lmeState.importedSheets) {
return;
}
if (lmeState.importedSheetsVisible) {
if (!lmeState.map.hasLayer(lmeState.importedSheets)) {
lmeState.importedSheets.addTo(lmeState.map);
}
lmeState.importedSheets.bringToFront();
return;
}
if (lmeState.map.hasLayer(lmeState.importedSheets)) {
lmeState.map.removeLayer(lmeState.importedSheets);
}
}
function setImportedMeasurementsVisible(enabled) {
lmeState.showImportedMeasurements = Boolean(enabled);
refreshImportedMeasurements();
refreshLayerManager();
setInfo(lmeState.showImportedMeasurements
? "Misure dei file GIS visibili."
: "Misure dei file GIS nascoste.");
}
function setDrawingMeasurementsVisible(enabled) {
lmeState.showDrawingMeasurements = Boolean(enabled);
refreshDrawingMeasurements();
refreshLayerManager();
setInfo(lmeState.showDrawingMeasurements
? "Misure dei disegni visibili."
: "Misure dei disegni nascoste.");
}
function fitImported() {
const layers = [lmeState.imported];
if (lmeState.importedSheetsVisible) {
layers.push(lmeState.importedSheets);
}
fitLayer(L.featureGroup(layers));
}
function clearImported() {
rememberMapChange();
clearManagedCategoryState("imported");
clearManagedCategoryState("importedSheets");
lmeState.imported.clearLayers();
lmeState.importedSheets.clearLayers();
lmeState.importedSheetsVisible = false;
applyImportedSheetVisibility();
lmeState.importedMeasurements.clearLayers();
setInfo("File GIS rimossi.");
updateGpsInsideState();
refreshLayerManager();
finishMapChange();
}
function addProjectMarker(name) {
rememberMapChange();
const center = lmeState.map.getCenter();
const label = (name || "Nuovo POI").trim();
const marker = L.marker(center, { pane: "drawingsPane", draggable: false }).addTo(lmeState.drawings);
marker.lmeManagerLabel = `POI: ${label}`;
marker.feature = {
type: "Feature",
properties: { name: label }
};
marker.bindPopup(`${escapeHtml(label)}
${center.lat.toFixed(6)}, ${center.lng.toFixed(6)}`).openPopup();
bindDrawingLayerSelection(marker);
refreshDrawingMeasurements();
refreshLayerManager();
selectDrawingLayer(marker);
finishMapChange();
}
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 = "Scegli quali disegni modificare";
L.drawLocal.edit.toolbar.buttons.remove = "Scegli quali disegni eliminare";
L.drawLocal.draw.handlers.polygon.tooltip.start = "Tocca il primo vertice dell'area.";
L.drawLocal.draw.handlers.polygon.tooltip.cont = "Tocca per aggiungere un vertice.";
L.drawLocal.draw.handlers.polygon.tooltip.end = "Tocca il primo punto per chiudere l'area.";
L.drawLocal.draw.handlers.polyline.tooltip.start = "Tocca il primo punto della distanza.";
L.drawLocal.draw.handlers.polyline.tooltip.cont = "Tocca per aggiungere un tratto.";
L.drawLocal.draw.handlers.polyline.tooltip.end = "Tocca l'ultimo punto per terminare.";
L.drawLocal.draw.handlers.circle.tooltip.start = "Tocca il centro e trascina per il raggio.";
}
function bindDrawingEvents() {
if (!lmeState.map || lmeState.drawEventsBound) {
return;
}
lmeState.map.on(L.Draw.Event.CREATED, event => {
if (lmeState.readOnly) {
setInfo("Il progetto e' in sola lettura.");
return;
}
snapDrawingLayer(event.layer);
annotateDrawingLayer(event.layer, event.layerType);
lmeState.drawings.addLayer(event.layer);
refreshDrawingMeasurements();
updateGpsInsideState();
const completedLabels = {
polygon: "Area completata: superficie, perimetro e lati aggiornati.",
rectangle: "Rettangolo completato: superficie, perimetro e lati aggiornati.",
polyline: "Distanza completata: lunghezza totale e tratti aggiornati.",
circle: "Cerchio completato: superficie, raggio e circonferenza aggiornati.",
marker: "Punto aggiunto alla mappa."
};
setInfo(completedLabels[event.layerType] || "Disegno completato.");
refreshLayerManager();
selectDrawingLayer(event.layer);
finishMapChange();
});
lmeState.map.on(L.Draw.Event.DRAWSTART, rememberMapChange);
lmeState.map.on(L.Draw.Event.EDITSTART, rememberMapChange);
lmeState.map.on(L.Draw.Event.EDITED, event => {
event.layers.eachLayer(layer => { snapDrawingLayer(layer); annotateDrawingLayer(layer); });
refreshDrawingMeasurements();
updateGpsInsideState();
refreshLayerManager();
finishMapChange();
});
lmeState.map.on(L.Draw.Event.DELETED, event => {
event.layers?.eachLayer(layer => lmeState.drawings.removeLayer(layer));
clearManagerHighlight();
refreshDrawingMeasurements();
updateGpsInsideState();
refreshLayerManager();
finishMapChange();
});
lmeState.map.on(L.Draw.Event.DRAWSTOP, () => {
lmeState.activeDrawHandler = null;
});
lmeState.drawEventsBound = true;
}
function enableDrawTools() {
if (lmeState.drawControl) {
setInfo("Strumenti di disegno gia attivi.");
return;
}
configureDrawLabels();
const orangeArea = {
pane: "drawingsPane",
renderer: lmeState.drawingsRenderer,
color: "#f97316",
weight: 4,
fillColor: "#fb923c",
fillOpacity: 0.2
};
lmeState.drawControl = new L.Control.Draw({
position: "topright",
edit: {
featureGroup: lmeState.drawings,
remove: false,
edit: {
selectedPathOptions: {
color: "#ff00d4",
fillColor: "#00fff0",
fillOpacity: 0.16,
maintainColor: false
}
}
},
draw: {
circle: { metric: 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",
renderer: lmeState.drawingsRenderer,
color: "#f97316",
weight: 4,
opacity: 0.95
}
},
rectangle: { metric: true, shapeOptions: orangeArea }
}
});
lmeState.map.addControl(lmeState.drawControl);
installDrawingScopeControls();
bindDrawingEvents();
setInfo("Barra strumenti attiva: seleziona un disegno sulla mappa, poi usa matita o cestino.");
}
function installDrawingScopeControls() {
const toolbar = drawingEditToolbar();
const container = toolbar?._toolbarContainer;
if (!container) {
return;
}
const editButton = toolbar._modes?.[L.EditToolbar.Edit.TYPE]?.button;
editButton?.addEventListener("click", event => {
event.preventDefault();
event.stopImmediatePropagation();
showDrawingScopeMenu(editButton, "edit");
}, true);
const button = L.DomUtil.create("a", "leaflet-draw-edit-remove", container);
button.href = "#";
button.title = L.drawLocal.edit.toolbar.buttons.remove;
button.setAttribute("role", "button");
button.setAttribute("aria-label", L.drawLocal.edit.toolbar.buttons.remove);
const accessibleLabel = L.DomUtil.create("span", "sr-only", button);
accessibleLabel.textContent = L.drawLocal.edit.toolbar.buttons.remove;
L.DomEvent.on(button, "click", L.DomEvent.stop)
.on(button, "mousedown", L.DomEvent.stopPropagation)
.on(button, "touchstart", L.DomEvent.stopPropagation)
.on(button, "click", () => showDrawingScopeMenu(button, "delete"));
lmeState.drawingDeleteButton = button;
updateDrawingSelectionActions();
}
function showDrawingScopeMenu(button, action) {
if (drawingEditActive()) {
setInfo("Salva o annulla la modifica corrente prima di scegliere un altro comando.");
return;
}
if (!lmeState.drawings?.getLayers?.().length) {
setInfo("Non ci sono disegni o POI su cui operare.");
return;
}
if (lmeState.drawingScopeMenu?.dataset.action === action) {
hideDrawingScopeMenu();
return;
}
hideDrawingScopeMenu();
const menu = document.createElement("ul");
menu.className = "leaflet-draw-actions lme-drawing-scope-actions";
menu.dataset.action = action;
menu.style.display = "block";
menu.style.top = `${button.offsetTop - 1}px`;
menu.innerHTML = `Solo selezionatoTutti`;
button.closest(".leaflet-draw-section")?.appendChild(menu);
lmeState.drawingScopeMenu = menu;
const hasSelection = Boolean(lmeState.drawingSelection?.getLayers?.().length);
const selectedAction = menu.querySelector("[data-drawing-scope='selected']");
selectedAction.setAttribute("aria-disabled", String(!hasSelection));
menu.querySelectorAll("a").forEach(link => {
L.DomEvent.on(link, "click", L.DomEvent.stop)
.on(link, "mousedown", L.DomEvent.stopPropagation)
.on(link, "touchstart", L.DomEvent.stopPropagation)
.on(link, "click", () => runDrawingScopeAction(action, link.dataset.drawingScope));
});
}
function hideDrawingScopeMenu() {
lmeState.drawingScopeMenu?.remove();
lmeState.drawingScopeMenu = null;
}
function runDrawingScopeAction(action, scope) {
const targetGroup = scope === "selected" ? lmeState.drawingSelection : lmeState.drawings;
if (!targetGroup?.getLayers?.().length) {
setInfo("Seleziona prima un oggetto sulla mappa.");
return;
}
hideDrawingScopeMenu();
if (action === "edit") {
startScopedDrawingEdit(targetGroup, scope);
} else if (scope === "selected") {
removeSelectedDrawing();
} else {
clearDrawings();
}
}
function startScopedDrawingEdit(targetGroup, scope) {
const toolbar = drawingEditToolbar();
const mode = toolbar?._modes?.[L.EditToolbar.Edit.TYPE];
if (!mode?.handler) {
return;
}
const locked = targetGroup.getLayers().filter(layer => layer?.feature?.properties?._lmeLocked);
if (locked.length) { setInfo("Sblocca gli oggetti prima di modificarli."); return; }
rememberMapChange();
mode.handler._featureGroup = targetGroup;
mode.handler.options.featureGroup = targetGroup;
mode.handler.enable();
setInfo(scope === "selected"
? "Modifica attiva sul solo oggetto selezionato."
: "Modifica attiva su tutti i disegni e POI.");
}
function removeSelectedDrawing() {
const layer = lmeState.drawingSelection?.getLayers?.()[0];
if (!layer) {
setInfo("Seleziona prima il disegno da eliminare toccandolo sulla mappa.");
return;
}
if (drawingEditActive()) {
setInfo("Salva o annulla la modifica prima di eliminare il disegno.");
return;
}
if (layer.feature?.properties?._lmeLocked) { setInfo("Sblocca prima l'oggetto selezionato."); return; }
const record = {
id: managedLayerId(layer, "drawings"),
categoryKey: "drawings",
layer,
parent: lmeState.drawings
};
const name = managedObjectLabel(record, 0);
if (!window.confirm(`Eliminare solo '${name}' dal progetto?`)) {
return;
}
rememberMapChange();
lmeState.drawingSelection.clearLayers();
lmeState.drawings.removeLayer(layer);
clearManagedObjectState(record.id);
clearManagerHighlight();
refreshDrawingMeasurements();
updateGpsInsideState();
refreshLayerManager();
finishMapChange();
setInfo(`'${name}' eliminato dal progetto.`);
}
function startMeasurementDrawing(mode) {
if (!lmeState.map) {
return;
}
bindDrawingEvents();
hideDrawingScopeMenu();
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",
renderer: lmeState.drawingsRenderer,
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",
renderer: lmeState.drawingsRenderer,
color: "#f97316",
weight: 4,
opacity: 0.95
}
}),
circle: () => new L.Draw.Circle(lmeState.map, {
metric: true,
feet: false,
showRadius: true,
shapeOptions: {
pane: "drawingsPane",
renderer: lmeState.drawingsRenderer,
color: "#f97316",
weight: 4,
fillColor: "#fb923c",
fillOpacity: 0.2
}
})
};
const createHandler = handlers[mode];
if (!createHandler) {
setInfo("Modalita di misura non riconosciuta.");
return;
}
lmeState.activeDrawHandler = createHandler();
lmeState.activeDrawHandler.enable();
const labels = {
area: "Tocca i vertici dell'area; chiudi toccando il primo punto.",
distance: "Tocca i punti del percorso; termina sull'ultimo punto.",
circle: "Tocca il centro e trascina fino al raggio desiderato."
};
setInfo(labels[mode]);
}
function annotateDrawingLayer(layer, layerType) {
layer.options.pane = "drawingsPane";
if (layer instanceof L.Path) {
layer.options.renderer = lmeState.drawingsRenderer;
}
layer.feature = layer.feature || { type: "Feature", properties: {} };
layer.feature.properties = layer.feature.properties || {};
if (layer instanceof L.Circle) {
layer.feature.properties._lmeShape = "circle";
layer.feature.properties._lmeRadius = layer.getRadius();
} else if (layerType) {
layer.feature.properties._lmeShape = layerType;
}
layer.lmeManagerLabel = drawingLayerLabel(layer, layerType);
bindDrawingLayerSelection(layer);
}
function bindDrawingLayerSelection(layer) {
if (layer.lmeDrawingSelectionBound) {
return;
}
layer.lmeDrawingSelectionBound = true;
layer.on("click", event => {
if (drawingEditActive()) {
return;
}
lmeState.ignoreMapClickUntil = Date.now() + 1000;
if (event.originalEvent) {
event.originalEvent._lmeDrawingSelected = true;
L.DomEvent.stopPropagation(event.originalEvent);
}
const id = managedLayerId(layer, "drawings");
if (event.originalEvent?.ctrlKey || event.originalEvent?.metaKey || event.originalEvent?.shiftKey) {
if (lmeState.managerSelectedObjectIds.has(id)) lmeState.managerSelectedObjectIds.delete(id);
else lmeState.managerSelectedObjectIds.add(id);
} else {
lmeState.managerSelectedObjectIds.clear();
lmeState.managerSelectedObjectIds.add(id);
}
selectDrawingLayer(layer);
refreshLayerManager();
});
}
function bindManagedMapSelection(layer, categoryKey, parent) {
if (layer.lmeManagedMapSelectionBound) return;
layer.lmeManagedMapSelectionBound = true;
layer.on("click", event => {
if (Date.now() < lmeState.ignoreMapClickUntil) return;
lmeState.ignoreMapClickUntil = Date.now() + 600;
if (event.originalEvent) {
event.originalEvent._lmeDrawingSelected = true;
L.DomEvent.stopPropagation(event.originalEvent);
}
const record = { id: managedLayerId(layer, categoryKey), categoryKey, layer, parent, source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente") };
const multiple = event.originalEvent?.ctrlKey || event.originalEvent?.metaKey || event.originalEvent?.shiftKey;
if (!multiple) lmeState.managerSelectedObjectIds.clear();
if (multiple && lmeState.managerSelectedObjectIds.has(record.id)) lmeState.managerSelectedObjectIds.delete(record.id);
else lmeState.managerSelectedObjectIds.add(record.id);
highlightManagedObject(record);
refreshLayerManager();
setInfo(`Selezionato dalla mappa: ${managedObjectEditableName(record) || "oggetto"}.`);
});
}
function setDrawToolsVisible(visible) {
if (visible) {
enableDrawTools();
return;
}
if (!lmeState.drawControl) {
return;
}
Object.values(lmeState.drawControl._toolbars || {}).forEach(toolbar => {
if (typeof toolbar.disable === "function") {
toolbar.disable();
}
});
lmeState.map.removeControl(lmeState.drawControl);
lmeState.drawControl = null;
lmeState.drawingDeleteButton = null;
lmeState.drawingScopeMenu = null;
setInfo("Strumenti di disegno nascosti.");
}
function clearDrawings() {
const count = managedGroupGeoJson(lmeState.drawings, "drawings").features.length;
if (!count) {
setInfo("Non ci sono disegni o POI da eliminare.");
return;
}
if (!window.confirm(`Eliminare ${count} disegni e POI dalla mappa?`)) {
return;
}
if (managedRecordsForCategory(layerManagerCategories().find(item => item.key === "drawings")).some(record => record.layer?.feature?.properties?._lmeLocked)) {
setInfo("Sblocca tutti i disegni prima di eliminarli."); return;
}
rememberMapChange();
drawingEditToolbar()?.disable?.();
lmeState.drawingSelection?.clearLayers();
clearManagerHighlight();
lmeState.drawings.clearLayers();
clearManagedCategoryState("drawings");
lmeState.drawingMeasurements.clearLayers();
updateGpsInsideState();
setInfo("Disegni e POI eliminati dalla mappa.");
refreshLayerManager();
finishMapChange();
}
function snapDrawingLayer(layer) {
if (!lmeState.snapEnabled || !layer || layer instanceof L.Circle || layer instanceof L.Marker) return;
const candidates = [];
const segments = [];
layerManagerCategories().forEach(category => managedRecordsForCategory(category).forEach(record => {
if (record.layer === layer) return;
lineLatLngGroups(record.layer?.getLatLngs?.() || []).forEach(line => {
line.forEach(point => candidates.push(point));
for (let index = 1; index < line.length; index++) segments.push([line[index - 1], line[index]]);
if (record.layer instanceof L.Polygon && line.length > 2) segments.push([line.at(-1), line[0]]);
});
const point = record.layer?.getLatLng?.(); if (point) candidates.push(point);
}));
const snapPoint = point => {
const pixel = lmeState.map.latLngToContainerPoint(point);
let nearest = null; let distance = lmeState.snapDistancePixels;
candidates.forEach(candidate => { const current = pixel.distanceTo(lmeState.map.latLngToContainerPoint(candidate)); if (current < distance) { distance = current; nearest = candidate; } });
segments.forEach(([start, end]) => {
const a = lmeState.map.latLngToContainerPoint(start); const b = lmeState.map.latLngToContainerPoint(end);
const lengthSquared = (b.x - a.x) ** 2 + (b.y - a.y) ** 2;
if (!lengthSquared) return;
const ratio = Math.max(0, Math.min(1, ((pixel.x - a.x) * (b.x - a.x) + (pixel.y - a.y) * (b.y - a.y)) / lengthSquared));
const projected = L.point(a.x + ratio * (b.x - a.x), a.y + ratio * (b.y - a.y));
const current = pixel.distanceTo(projected);
if (current < distance) { distance = current; nearest = lmeState.map.containerPointToLatLng(projected); }
const midpoint = L.point((a.x + b.x) / 2, (a.y + b.y) / 2);
const midpointDistance = pixel.distanceTo(midpoint);
if (midpointDistance < distance) { distance = midpointDistance; nearest = lmeState.map.containerPointToLatLng(midpoint); }
});
return nearest ? L.latLng(nearest.lat, nearest.lng) : point;
};
const transform = value => Array.isArray(value) ? value.map(transform) : snapPoint(value);
layer.setLatLngs(transform(layer.getLatLngs()));
}
function scheduleMeasurementRefresh() {
window.clearTimeout(lmeState.measurementRefreshTimer);
lmeState.measurementRefreshTimer = window.setTimeout(() => {
refreshImportedMeasurements();
refreshDrawingMeasurements();
}, 80);
}
function refreshImportedMeasurements() {
renderMeasurements(
lmeState.imported,
lmeState.importedMeasurements,
lmeState.showImportedMeasurements,
"imported");
}
function refreshDrawingMeasurements() {
renderMeasurements(
lmeState.drawings,
lmeState.drawingMeasurements,
lmeState.showDrawingMeasurements,
"drawing");
}
function renderMeasurements(sourceGroup, targetGroup, visible, sourceType) {
if (!sourceGroup || !targetGroup) {
return;
}
targetGroup.clearLayers();
if (!visible || !lmeState.map.hasLayer(sourceGroup)) {
return;
}
const context = {
count: 0,
max: 600,
showSides: lmeState.map.getZoom() >= 17,
bounds: lmeState.map.getBounds(),
sourceType
};
visitMeasurableLayers(sourceGroup, layer => {
const categoryKey = sourceType === "imported" ? "imported" : "drawings";
const objectId = managedLayerId(layer, categoryKey);
const showSummary = !lmeState.managerHiddenMeasurementSummaryIds.has(objectId);
const showSides = !lmeState.managerHiddenMeasurementSideIds.has(objectId);
if (!showSummary && !showSides) {
return;
}
if (context.count >= context.max || !layerIsInView(layer, context.bounds)) {
return;
}
const layerContext = {
...context,
showSummary,
showSides: context.showSides && showSides
};
if (layer instanceof L.Circle) {
renderCircleMeasurement(layer, targetGroup, layerContext);
} else if (layer instanceof L.Polygon) {
renderPolygonMeasurement(layer, targetGroup, layerContext);
} else if (layer instanceof L.Polyline) {
renderPolylineMeasurement(layer, targetGroup, layerContext);
}
});
}
function visitMeasurableLayers(layer, visitor) {
if (layer instanceof L.Circle || layer instanceof L.Polygon || layer instanceof L.Polyline) {
visitor(layer);
return;
}
if (typeof layer.eachLayer === "function") {
layer.eachLayer(child => visitMeasurableLayers(child, visitor));
}
}
function layerIsInView(layer, mapBounds) {
if (typeof layer.getBounds === "function") {
const bounds = layer.getBounds();
return !bounds.isValid || !bounds.isValid() || bounds.intersects(mapBounds);
}
if (typeof layer.getLatLng === "function") {
return mapBounds.contains(layer.getLatLng());
}
return true;
}
function renderCircleMeasurement(layer, targetGroup, context) {
if (!context.showSummary) {
return;
}
const radius = layer.getRadius();
const area = Math.PI * radius * radius;
const circumference = 2 * Math.PI * radius;
addMeasurementLabel(
targetGroup,
layer.getLatLng(),
`S: ${formatArea(area)}
R: ${formatDistance(radius)} ยท C: ${formatDistance(circumference)}`,
"summary",
context);
}
function renderPolygonMeasurement(layer, targetGroup, context) {
const polygons = polygonLatLngGroups(layer.getLatLngs());
let area = 0;
let perimeter = 0;
polygons.forEach(rings => {
rings.forEach((ring, index) => {
const ringArea = Math.abs(L.GeometryUtil.geodesicArea(ring));
area += index === 0 ? ringArea : -ringArea;
perimeter += latLngPathLength(ring, true);
if (context.showSides) {
addSegmentLabels(ring, true, targetGroup, context);
}
});
});
const center = typeof layer.getCenter === "function"
? layer.getCenter()
: layer.getBounds().getCenter();
if (context.showSummary) {
addMeasurementLabel(
targetGroup,
center,
`S: ${formatArea(Math.max(0, area))}
P: ${formatDistance(perimeter)}`,
"summary",
context);
}
}
function renderPolylineMeasurement(layer, targetGroup, context) {
const lines = lineLatLngGroups(layer.getLatLngs());
const length = lines.reduce((total, line) => total + latLngPathLength(line, false), 0);
if (context.showSides) {
lines.forEach(line => addSegmentLabels(line, false, targetGroup, context));
}
const longest = lines.reduce((current, line) => line.length > current.length ? line : current, []);
if (context.showSummary && longest.length) {
addMeasurementLabel(
targetGroup,
longest[Math.floor(longest.length / 2)],
`L: ${formatDistance(length)}`,
"summary",
context);
}
}
function polygonLatLngGroups(latLngs) {
if (!Array.isArray(latLngs) || !latLngs.length) {
return [];
}
if (isLatLng(latLngs[0])) {
return [[latLngs]];
}
if (Array.isArray(latLngs[0]) && isLatLng(latLngs[0][0])) {
return [latLngs];
}
return latLngs.flatMap(polygonLatLngGroups);
}
function lineLatLngGroups(latLngs) {
if (!Array.isArray(latLngs) || !latLngs.length) {
return [];
}
if (isLatLng(latLngs[0])) {
return [latLngs];
}
return latLngs.flatMap(lineLatLngGroups);
}
function isLatLng(value) {
return value && Number.isFinite(value.lat) && Number.isFinite(value.lng);
}
function latLngPathLength(points, closed) {
if (points.length < 2) {
return 0;
}
let total = 0;
for (let index = 1; index < points.length; index += 1) {
total += lmeState.map.distance(points[index - 1], points[index]);
}
if (closed && !points[0].equals(points[points.length - 1])) {
total += lmeState.map.distance(points[points.length - 1], points[0]);
}
return total;
}
function addSegmentLabels(points, closed, targetGroup, context) {
if (points.length < 2) {
return;
}
const segmentCount = closed ? points.length : points.length - 1;
for (let index = 0; index < segmentCount && context.count < context.max; index += 1) {
const start = points[index];
const end = points[(index + 1) % points.length];
if (start.equals(end)) {
continue;
}
addMeasurementLabel(
targetGroup,
L.latLng((start.lat + end.lat) / 2, (start.lng + end.lng) / 2),
formatDistance(lmeState.map.distance(start, end)),
"side",
context);
}
}
function addMeasurementLabel(targetGroup, latLng, html, labelType, context) {
if (context.count >= context.max) {
return;
}
L.marker(latLng, {
pane: 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])
])
}
});
}
const exported = requested.selectedOnly
? features.filter(feature => lmeState.managerSelectedObjectIds.has(feature?.properties?._lmeManagerId))
: features;
return JSON.stringify({ type: "FeatureCollection", features: exported });
}
function appendExportFeatures(target, collection, category, predicate = () => true) {
for (const sourceFeature of collection?.features || []) {
if (!sourceFeature?.geometry || !predicate(sourceFeature)) {
continue;
}
const feature = JSON.parse(JSON.stringify(sourceFeature));
feature.properties = { ...(feature.properties || {}), _lmeCategory: category };
if (feature.properties._lmeShape === "circle"
&& feature.geometry.type === "Point"
&& Number(feature.properties._lmeRadius) > 0) {
feature.geometry = circleToPolygon(feature.geometry.coordinates, Number(feature.properties._lmeRadius));
}
target.push(feature);
}
}
function isPoiFeature(feature) {
return feature?.geometry?.type === "Point"
&& feature?.properties?._lmeShape !== "circle";
}
function circleToPolygon(center, radiusMeters) {
const longitude = Number(center?.[0]);
const latitude = Number(center?.[1]);
const earthRadius = 6378137;
const latitudeRadians = latitude * Math.PI / 180;
const ring = [];
for (let index = 0; index <= 64; index += 1) {
const angle = index / 64 * Math.PI * 2;
const north = Math.sin(angle) * radiusMeters;
const east = Math.cos(angle) * radiusMeters;
ring.push([
longitude + east / (earthRadius * Math.cos(latitudeRadians)) * 180 / Math.PI,
latitude + north / earthRadius * 180 / Math.PI
]);
}
return { type: "Polygon", coordinates: [ring] };
}
function createProjectSnapshot(includeCadastralParcels) {
const center = lmeState.map.getCenter();
return JSON.stringify({
schemaVersion: 5,
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])
]),
trailStartedAt: lmeState.gpsTrailStartedAt,
auditLog: [...lmeState.auditLog]
});
}
function getLegacyProjectSnapshot() {
return localStorage.getItem("lmeMapWorkspace");
}
function clearLegacyProjectSnapshot() {
localStorage.removeItem("lmeMapWorkspace");
}
function loadProjectSnapshot(raw, projectName) {
try {
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!data || typeof data !== "object") {
throw new Error("contenuto non valido");
}
resetLayerManagerProjectState();
lmeState.drawings.clearLayers();
lmeState.imported.clearLayers();
lmeState.importedSheets.clearLayers();
lmeState.drawingMeasurements.clearLayers();
lmeState.importedMeasurements.clearLayers();
lmeState.selectedParcelsLayer.clearLayers();
lmeState.gpsParcelLayer.clearLayers();
const restoredDrawings = L.geoJSON(data.drawings || emptyFeatureCollection(), {
pane: "drawingsPane",
style: storedFeatureStyle,
pointToLayer: (feature, latlng) => {
const properties = feature?.properties || {};
if (properties._lmeShape === "circle" && Number(properties._lmeRadius) > 0) {
return L.circle(latlng, {
pane: "drawingsPane",
renderer: lmeState.drawingsRenderer,
radius: Number(properties._lmeRadius),
color: "#f97316",
weight: 3,
fillColor: "#fb923c",
fillOpacity: 0.2
});
}
return L.marker(latlng, { pane: "drawingsPane", draggable: false });
},
onEachFeature: (feature, layer) => {
bindStoredFeaturePopup(feature, layer);
annotateDrawingLayer(layer, feature?.properties?._lmeShape);
applyStyleFromProperties(layer);
}
});
restoredDrawings.eachLayer(layer => lmeState.drawings.addLayer(layer));
const importedParts = partitionStoredImportedFeatures(
data.imported || emptyFeatureCollection(),
data.importedSheets || emptyFeatureCollection());
addImportedFeatures(importedParts.parcels);
addImportedSheetFeatures(importedParts.sheets);
const projectDisplayOptions = data.schemaVersion >= 3 && data.displayOptions
? data.displayOptions
: null;
lmeState.importedSheetsVisible = projectDisplayOptions
? Boolean(projectDisplayOptions.importedSheetsVisible)
: data.schemaVersion >= 2 && Boolean(data.importedSheetsVisible);
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.gpsTrailStartedAt = data.trailStartedAt || null;
lmeState.auditLog = Array.isArray(data.auditLog) ? data.auditLog.slice(-250) : [];
lmeState.gpsTrail.setLatLngs(lmeState.gpsTrailPoints);
if (projectDisplayOptions) {
applyProjectDisplayOptions(projectDisplayOptions);
}
refreshImportedMeasurements();
refreshDrawingMeasurements();
refreshPermanentLabels();
const projectLayerList = [
lmeState.drawings,
lmeState.imported,
lmeState.selectedParcelsLayer,
lmeState.gpsTrail
];
if (lmeState.importedSheetsVisible) {
projectLayerList.push(lmeState.importedSheets);
}
const projectLayers = L.featureGroup(projectLayerList);
if (projectLayers.getBounds().isValid()) {
fitLayer(projectLayers);
} else if (Number.isFinite(data.view?.latitude)
&& Number.isFinite(data.view?.longitude)
&& Number.isFinite(data.view?.zoom)) {
lmeState.map.setView([data.view.latitude, data.view.longitude], data.view.zoom);
}
const parcelCount = lmeState.selectedParcelsLayer.toGeoJSON().features?.length || 0;
setText("parcelState", parcelCount
? `${parcelCount} particelle del progetto`
: "Nessuna particella selezionata");
updateGpsInsideState();
const hiddenSheets = importedParts.sheets.features.length && !lmeState.importedSheetsVisible
? ` ${importedParts.sheets.features.length} elementi del foglio sono nascosti.`
: "";
setInfo(`Progetto caricato: ${projectName || "senza nome"}.${hiddenSheets}`);
refreshLayerManager();
if (!lmeState.historyBusy) {
lmeState.isDirty = false;
lmeState.undoStack = [];
lmeState.redoStack = [];
void lmeState.dotNetReference?.invokeMethodAsync("OnProjectSaved");
}
return captureDisplayOptions();
} catch (error) {
setInfo(`Progetto non leggibile: ${error.message}`);
throw error;
}
}
function captureDisplayOptions() {
return {
baseLayer: lmeState.currentBaseName,
cadastreOpacity: lmeState.cadastreOpacity,
cadastreLayers: [...lmeState.cadastreLayers],
includeKmlSheetLayer: lmeState.includeKmlSheetLayer,
importedVisible: lmeState.importedVisible,
importedSheetsVisible: lmeState.importedSheetsVisible,
showImportedMeasurements: lmeState.showImportedMeasurements,
showDrawingMeasurements: lmeState.showDrawingMeasurements,
drawToolsVisible: Boolean(lmeState.drawControl),
gpsPositionVisible: lmeState.gpsPositionVisible,
selectedParcelsVisible: lmeState.selectedParcelsVisible,
layerOrder: [...lmeState.layerOrder],
layerObjectOrder: { ...lmeState.managerObjectOrder },
layerCategoryNames: { ...lmeState.managerCategoryNames },
removedLayerCategories: [...lmeState.managerRemovedCategories],
hiddenLayerObjects: [...lmeState.managerHiddenObjectIds],
hiddenLayerMeasurementSummaries: [...lmeState.managerHiddenMeasurementSummaryIds],
hiddenLayerMeasurementSides: [...lmeState.managerHiddenMeasurementSideIds],
hiddenLayerCategories: [...lmeState.managerHiddenCategories],
trailEnabled: lmeState.gpsTrailEnabled
};
}
function applyProjectDisplayOptions(options) {
const supportedCadastreLayers = new Set([
"CP.CadastralParcel",
"codice_plla",
"CP.CadastralZoning",
"fabbricati",
"strade",
"acque"
]);
if (Array.isArray(options.cadastreLayers)) {
lmeState.cadastreLayers = options.cadastreLayers.filter(layer => supportedCadastreLayers.has(layer));
}
const opacity = Number(options.cadastreOpacity);
if (Number.isFinite(opacity)) {
lmeState.cadastreOpacity = Math.max(0, Math.min(1, opacity));
}
lmeState.includeKmlSheetLayer = Boolean(options.includeKmlSheetLayer);
lmeState.importedVisible = options.importedVisible !== false;
lmeState.importedSheetsVisible = Boolean(options.importedSheetsVisible);
lmeState.showImportedMeasurements = Boolean(options.showImportedMeasurements);
lmeState.showDrawingMeasurements = options.showDrawingMeasurements !== false;
if (Array.isArray(options.layerOrder)) {
setLayerOrder(options.layerOrder);
}
if (options.layerObjectOrder && typeof options.layerObjectOrder === "object") {
lmeState.managerObjectOrder = options.layerObjectOrder;
}
if (options.layerCategoryNames && typeof options.layerCategoryNames === "object") {
lmeState.managerCategoryNames = { ...options.layerCategoryNames };
}
if (Array.isArray(options.removedLayerCategories)) {
lmeState.managerRemovedCategories = new Set(options.removedLayerCategories);
}
if (Array.isArray(options.hiddenLayerObjects)) {
lmeState.managerHiddenObjectIds = new Set(options.hiddenLayerObjects);
}
if (Array.isArray(options.hiddenLayerMeasurementSummaries)) {
lmeState.managerHiddenMeasurementSummaryIds = new Set(options.hiddenLayerMeasurementSummaries);
}
if (Array.isArray(options.hiddenLayerMeasurementSides)) {
lmeState.managerHiddenMeasurementSideIds = new Set(options.hiddenLayerMeasurementSides);
}
if (Array.isArray(options.hiddenLayerCategories)) {
lmeState.managerHiddenCategories = new Set(options.hiddenLayerCategories);
}
applyImportedSheetVisibility();
setImportedLayerVisible(lmeState.importedVisible);
refreshCadastreLayer();
if (typeof options.baseLayer === "string") {
setBaseLayer(options.baseLayer);
}
setGpsPositionVisible(options.gpsPositionVisible !== false);
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);
}
}
});
});
const categoryName = lmeState.managerCategoryNames[categoryKey];
if (categoryName) {
features.forEach(feature => {
feature.properties = { ...(feature.properties || {}), _lmeCategoryName: categoryName };
});
}
return { type: "FeatureCollection", features };
}
function resetLayerManagerProjectState() {
drawingEditToolbar()?.disable?.();
lmeState.drawingSelection?.clearLayers();
lmeState.managerHiddenObjects.clear();
lmeState.managerHiddenObjectIds.clear();
lmeState.managerHiddenMeasurementSummaryIds.clear();
lmeState.managerHiddenMeasurementSideIds.clear();
lmeState.managerHiddenCategories.clear();
lmeState.managerRemovedCategories.clear();
lmeState.managerObjectOrder = {};
lmeState.managerCategoryNames = {};
lmeState.managerSelectedObjectIds.clear();
lmeState.permanentLabels?.clearLayers();
clearManagerHighlight();
}
function clearManagedCategoryState(categoryKey) {
const category = layerManagerCategories().find(item => item.key === categoryKey);
if (category) {
managedRecordsForCategory(category).forEach(record => {
lmeState.managerHiddenMeasurementSummaryIds.delete(record.id);
lmeState.managerHiddenMeasurementSideIds.delete(record.id);
});
}
[...lmeState.managerHiddenObjects.entries()].forEach(([id, record]) => {
if (record.categoryKey === categoryKey) {
lmeState.managerHiddenObjects.delete(id);
lmeState.managerHiddenObjectIds.delete(id);
}
});
lmeState.managerHiddenCategories.delete(categoryKey);
delete lmeState.managerObjectOrder[categoryKey];
}
function bindStoredFeaturePopup(feature, layer) {
updateStoredFeaturePopup(layer, feature?.properties);
}
function updateStoredFeaturePopup(layer, sourceProperties) {
const properties = sourceProperties || layer?.feature?.properties || {};
const label = properties.name || properties.Name || properties.label || properties.LABEL;
const notes = String(properties._lmeNotes || "").trim();
const photo = String(properties._lmePhoto || "");
if (label || notes || photo) {
const image = photo.startsWith("data:image/") ? `` : "";
layer.bindPopup(`${label ? `${escapeHtml(label)}` : ""}${notes ? `${escapeHtml(notes)}
` : ""}${image}`);
return;
}
if (typeof layer?.unbindPopup === "function") {
layer.unbindPopup();
}
}
function offlineDatabase() {
if (lmeState.offlineDbPromise) return lmeState.offlineDbPromise;
lmeState.offlineDbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open("lmeOfflineMaps", 1);
request.onupgradeneeded = () => request.result.createObjectStore("tiles", { keyPath: "key" });
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return lmeState.offlineDbPromise;
}
async function offlineTileGet(key) {
const db = await offlineDatabase();
return new Promise((resolve, reject) => {
const request = db.transaction("tiles", "readonly").objectStore("tiles").get(key);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
async function offlineTilePut(value) {
const db = await offlineDatabase();
return new Promise((resolve, reject) => {
const request = db.transaction("tiles", "readwrite").objectStore("tiles").put(value);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
function createOfflineTileLayer() {
const layer = L.gridLayer({ attribution: "Tessere salvate offline", maxZoom: 22 });
layer.createTile = (coords, done) => {
const tile = document.createElement("img");
tile.alt = "";
tile.className = "lme-offline-image-tile";
offlineTileGet(`${coords.z}/${coords.x}/${coords.y}`).then(record => {
if (!record?.blob) {
tile.className = "lme-offline-tile";
done(null, tile);
return;
}
const url = URL.createObjectURL(record.blob);
tile.onload = () => { URL.revokeObjectURL(url); done(null, tile); };
tile.onerror = error => { URL.revokeObjectURL(url); done(error, tile); };
tile.src = url;
}).catch(error => done(error, tile));
return tile;
};
return layer;
}
function offlineSourceUrl(provider, z, x, y) {
const urls = {
osm: `https://a.tile.openstreetmap.org/${z}/${x}/${y}.png`,
satellite: `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${z}/${y}/${x}`,
topographic: `https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/${z}/${y}/${x}`,
"streets-esri": `https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/${z}/${y}/${x}`,
terrain: `https://a.tile.opentopomap.org/${z}/${x}/${y}.png`,
carto: `https://a.basemaps.cartocdn.com/rastertiles/voyager/${z}/${x}/${y}.png`
};
return urls[provider] || null;
}
function longitudeToTileX(longitude, zoom) {
return Math.floor((longitude + 180) / 360 * 2 ** zoom);
}
function latitudeToTileY(latitude, zoom) {
const value = Math.max(-85.0511, Math.min(85.0511, latitude)) * Math.PI / 180;
return Math.floor((1 - Math.log(Math.tan(value) + 1 / Math.cos(value)) / Math.PI) / 2 * 2 ** zoom);
}
async function downloadOfflineArea(extraZoomLevels = 2) {
const provider = lmeState.currentBaseName;
if (!offlineSourceUrl(provider, 0, 0, 0)) {
throw new Error("Per il download offline usa prima uno sfondo Esri, OpenStreetMap, OpenTopoMap o CARTO.");
}
const bounds = lmeState.map.getBounds();
const startZoom = Math.max(1, lmeState.map.getZoom());
const endZoom = Math.min(19, startZoom + Math.max(0, Math.min(4, Number(extraZoomLevels) || 0)));
const tiles = [];
for (let z = startZoom; z <= endZoom; z++) {
const max = 2 ** z - 1;
const x1 = Math.max(0, Math.min(max, longitudeToTileX(bounds.getWest(), z)));
const x2 = Math.max(0, Math.min(max, longitudeToTileX(bounds.getEast(), z)));
const y1 = Math.max(0, Math.min(max, latitudeToTileY(bounds.getNorth(), z)));
const y2 = Math.max(0, Math.min(max, latitudeToTileY(bounds.getSouth(), z)));
for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) {
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) tiles.push({ z, x, y });
}
}
if (tiles.length > 600) throw new Error(`Area troppo ampia (${tiles.length} tessere). Aumenta lo zoom o riduci la zona visibile.`);
let saved = 0;
for (const tile of tiles) {
const key = `${tile.z}/${tile.x}/${tile.y}`;
if (await offlineTileGet(key)) { saved++; continue; }
const response = await fetch(offlineSourceUrl(provider, tile.z, tile.x, tile.y), { mode: "cors" });
if (!response.ok) continue;
await offlineTilePut({ key, blob: await response.blob(), provider, savedAt: new Date().toISOString() });
saved++;
if (saved % 10 === 0) setInfo(`Mappe offline: ${saved}/${tiles.length} tessere salvate...`);
}
lmeState.offlineTileCount = await getOfflineTileStats();
lmeState.baseLayers.offline.redraw();
setInfo(`Area offline pronta: ${saved} tessere, zoom ${startZoom}-${endZoom}.`);
return { saved, requested: tiles.length, startZoom, endZoom };
}
async function getOfflineTileStats() {
const db = await offlineDatabase();
return new Promise((resolve, reject) => {
const request = db.transaction("tiles", "readonly").objectStore("tiles").count();
request.onsuccess = () => resolve(request.result || 0);
request.onerror = () => reject(request.error);
});
}
async function clearOfflineTiles() {
const db = await offlineDatabase();
await new Promise((resolve, reject) => {
const request = db.transaction("tiles", "readwrite").objectStore("tiles").clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
lmeState.offlineTileCount = 0;
lmeState.baseLayers.offline.redraw();
setInfo("Archivio mappe offline svuotato.");
return true;
}
function setBaseLayer(name) {
const selected = lmeState.baseLayers[name] || lmeState.baseLayers.satellite;
if (lmeState.currentBase) {
lmeState.map.removeLayer(lmeState.currentBase);
}
lmeState.currentBase = selected.addTo(lmeState.map);
lmeState.currentBaseName = selected === lmeState.baseLayers[name] ? name : "satellite";
lmeState.currentBase.bringToBack();
const labels = {
satellite: "Satellite Esri",
osm: "OpenStreetMap",
"streets-esri": "Strade dettagliate Esri",
topographic: "Topografica Esri",
terrain: "OpenTopoMap",
carto: "CARTO Voyager",
offline: "Mappe salvate offline",
"azure-road": "Strade Azure Maps",
"azure-satellite": "Satellite Azure Maps",
"google-satellite": "Satellite Google Maps",
"google-roadmap": "Strade Google Maps"
};
setInfo(`Sfondo attivo: ${labels[name] || labels.satellite}. Il catasto AdE resta sovrapposto.`);
if (lmeState.currentBaseName.startsWith("azure-")) {
void updateAzureAttribution();
} else {
clearAzureAttribution();
}
}
function configureGoogleMaps(apiKey) {
const key = (apiKey || "").trim();
if (!key) {
return Promise.reject(new Error("Chiave Google Maps mancante."));
}
if (window.google?.maps && L.gridLayer.googleMutant) {
createGoogleLayers();
return Promise.resolve();
}
if (lmeState.googleMapsLoading) {
return lmeState.googleMapsLoading;
}
lmeState.googleMapsLoading = new Promise((resolve, reject) => {
const callbackName = `lmeGoogleMapsReady${Date.now()}`;
const script = document.createElement("script");
window[callbackName] = () => {
delete window[callbackName];
createGoogleLayers();
resolve();
};
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&loading=async&callback=${callbackName}`;
script.async = true;
script.onerror = () => {
delete window[callbackName];
lmeState.googleMapsLoading = null;
reject(new Error("Impossibile caricare Google Maps. Controlla chiave, API e connessione."));
};
document.head.appendChild(script);
});
return lmeState.googleMapsLoading;
}
function createGoogleLayers() {
if (!L.gridLayer.googleMutant) {
throw new Error("Modulo Google Maps non disponibile.");
}
lmeState.baseLayers["google-satellite"] ||= L.gridLayer.googleMutant({ type: "hybrid", maxZoom: 22 });
lmeState.baseLayers["google-roadmap"] ||= L.gridLayer.googleMutant({ type: "roadmap", maxZoom: 22 });
}
function configureAzureMaps(subscriptionKey) {
const key = (subscriptionKey || "").trim();
if (!key) {
throw new Error("Chiave Azure Maps mancante.");
}
if (lmeState.azureMapsKey === key
&& lmeState.baseLayers["azure-road"]
&& lmeState.baseLayers["azure-satellite"]) {
return;
}
const activeAzureLayer = lmeState.currentBaseName.startsWith("azure-")
? lmeState.currentBaseName
: null;
if (activeAzureLayer && lmeState.currentBase) {
lmeState.map.removeLayer(lmeState.currentBase);
}
lmeState.azureMapsKey = key;
lmeState.baseLayers["azure-road"] = createAzureLayer(key, "microsoft.base.road", 22);
lmeState.baseLayers["azure-satellite"] = createAzureLayer(key, "microsoft.imagery", 19);
if (activeAzureLayer) {
lmeState.currentBase = lmeState.baseLayers[activeAzureLayer].addTo(lmeState.map);
lmeState.currentBase.bringToBack();
void updateAzureAttribution();
}
}
function clearAzureMaps() {
if (lmeState.currentBaseName.startsWith("azure-") && lmeState.currentBase) {
lmeState.map.removeLayer(lmeState.currentBase);
lmeState.currentBase = lmeState.baseLayers.satellite.addTo(lmeState.map);
lmeState.currentBase.bringToBack();
lmeState.currentBaseName = "satellite";
}
delete lmeState.baseLayers["azure-road"];
delete lmeState.baseLayers["azure-satellite"];
lmeState.azureMapsKey = null;
clearAzureAttribution();
}
function createAzureLayer(key, tilesetId, maxNativeZoom) {
const url = `https://atlas.microsoft.com/map/tile?api-version=2024-04-01&tilesetId=${tilesetId}&zoom={z}&x={x}&y={y}&tileSize=256&language=it-IT&view=Auto&subscription-key=${encodeURIComponent(key)}`;
const layer = L.tileLayer(url, {
minZoom: tilesetId === "microsoft.imagery" ? 1 : 0,
maxNativeZoom,
maxZoom: 22,
attribution: "Microsoft Azure Maps"
});
let errorShown = false;
layer.on("tileerror", () => {
if (!errorShown) {
errorShown = true;
setInfo("Azure Maps non ha restituito le tessere. Controlla quota e permessi Render della chiave.");
}
});
return layer;
}
async function updateAzureAttribution() {
if (!lmeState.azureMapsKey || !lmeState.currentBaseName.startsWith("azure-")) {
return;
}
const requestedLayer = lmeState.currentBaseName;
const tilesetId = requestedLayer === "azure-satellite" ? "microsoft.imagery" : "microsoft.base.road";
const bounds = lmeState.map.getBounds();
const zoom = Math.min(lmeState.map.getZoom(), tilesetId === "microsoft.imagery" ? 19 : 22);
const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()].join(",");
const url = `https://atlas.microsoft.com/map/attribution?api-version=2024-04-01&tilesetId=${tilesetId}&zoom=${zoom}&bounds=${bbox}`;
try {
const response = await fetch(url, {
headers: {
Accept: "application/json",
"subscription-key": lmeState.azureMapsKey
}
});
if (!response.ok) {
return;
}
const payload = await response.json();
if (lmeState.currentBaseName !== requestedLayer) {
return;
}
const attribution = [...new Set((payload.copyrights || []).map(htmlToText).filter(Boolean))].join(" | ");
setAzureAttribution(attribution);
} catch {
clearAzureAttribution();
}
}
function setAzureAttribution(text) {
clearAzureAttribution();
if (!text) {
return;
}
lmeState.azureAttribution = escapeHtml(text);
}
function clearAzureAttribution() {
lmeState.azureAttribution = null;
}
function htmlToText(value) {
const element = document.createElement("div");
element.innerHTML = String(value || "");
return element.textContent?.trim() || "";
}
function setCadastreOpacity(value) {
const opacity = Number(value);
if (!Number.isFinite(opacity)) {
return;
}
lmeState.cadastreOpacity = Math.max(0, Math.min(1, opacity));
if (lmeState.cadastre) {
lmeState.cadastre.setOpacity(lmeState.cadastreOpacity);
}
}
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;
if (count) rememberMapChange();
lmeState.selectedParcelsLayer.clearLayers();
clearManagedCategoryState("selectedParcels");
updateGpsInsideState();
setInfo(count ? `${count} particelle selezionate rimosse.` : "Non ci sono particelle selezionate da rimuovere.");
refreshLayerManager();
if (count) finishMapChange();
}
function setSelectedParcelsVisible(enabled) {
lmeState.selectedParcelsVisible = Boolean(enabled);
if (!lmeState.map || !lmeState.selectedParcelsLayer) {
return;
}
if (lmeState.selectedParcelsVisible) {
if (!lmeState.map.hasLayer(lmeState.selectedParcelsLayer)) {
lmeState.selectedParcelsLayer.addTo(lmeState.map);
}
lmeState.selectedParcelsLayer.bringToFront();
const count = lmeState.selectedParcelsLayer.toGeoJSON().features?.length || 0;
setInfo(count
? `${count} particelle selezionate visibili.`
: "Nessuna particella selezionata da mostrare.");
return;
}
if (lmeState.map.hasLayer(lmeState.selectedParcelsLayer)) {
lmeState.map.removeLayer(lmeState.selectedParcelsLayer);
}
setInfo("Particelle selezionate nascoste; i dati restano nel progetto.");
}
function clearMapElements() {
const count = managedGroupGeoJson(lmeState.selectedParcelsLayer, "selectedParcels").features.length
+ managedGroupGeoJson(lmeState.gpsParcelLayer, "gpsParcels").features.length
+ managedGroupGeoJson(lmeState.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;
}
rememberMapChange();
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();
finishMapChange();
}
function startGps() {
if (!navigator.geolocation) {
setInfo("Geolocalizzazione non disponibile sul dispositivo.");
setText("gpsState", "GPS non disponibile");
return;
}
if (lmeState.gpsWatchId !== null) {
setInfo("Tracking GPS gia attivo.");
return;
}
lmeState.gpsWatchId = navigator.geolocation.watchPosition(onGpsPosition, onGpsError, {
enableHighAccuracy: true,
maximumAge: 1000,
timeout: 15000
});
setText("gpsState", "Aggancio GPS in corso...");
setInfo("Tracking GPS avviato.");
}
function stopGps() {
if (lmeState.gpsWatchId !== null) {
navigator.geolocation.clearWatch(lmeState.gpsWatchId);
lmeState.gpsWatchId = null;
}
setText("gpsState", "GPS fermo");
setInfo("Tracking GPS fermato.");
}
function setNativeGpsPosition(location) {
onGpsPosition({
coords: {
latitude: location.latitude,
longitude: location.longitude,
accuracy: location.accuracy || 0,
altitude: location.altitude,
heading: location.course,
speed: location.speed
},
timestamp: location.timestamp
});
}
function setGpsPositionVisible(enabled) {
lmeState.gpsPositionVisible = Boolean(enabled);
if (!lmeState.gpsPositionVisible) {
if (lmeState.gpsMarker && lmeState.map.hasLayer(lmeState.gpsMarker)) {
lmeState.map.removeLayer(lmeState.gpsMarker);
}
if (lmeState.gpsAccuracy && lmeState.map.hasLayer(lmeState.gpsAccuracy)) {
lmeState.map.removeLayer(lmeState.gpsAccuracy);
}
setInfo("Punto e cerchio GPS nascosti. Il rilevamento continua.");
return;
}
if (lmeState.hasLiveGpsFix && lmeState.lastPosition) {
updateGpsPositionLayers(lmeState.lastPosition, lmeState.lastAccuracy);
}
setInfo("Punto e cerchio GPS visibili.");
}
function setGpsError(error) {
setText("gpsState", "GPS non disponibile");
setInfo(error || "Errore GPS.");
}
function centerOnGps() {
if (!lmeState.lastPosition) {
setInfo("Nessuna posizione GPS ancora disponibile.");
return;
}
lmeState.map.setView(lmeState.lastPosition, Math.max(lmeState.map.getZoom(), 19));
}
function toggleTrail(enabled) {
lmeState.gpsTrailEnabled = enabled;
if (enabled && !lmeState.gpsTrailStartedAt) lmeState.gpsTrailStartedAt = new Date().toISOString();
setInfo(enabled ? "Registrazione percorso avviata." : "Registrazione percorso in pausa.");
}
function clearGpsTrail() {
if (!lmeState.gpsTrailPoints.length) { setInfo("La traccia GPS e' vuota."); return false; }
if (!window.confirm("Eliminare definitivamente la traccia GPS corrente?")) return false;
rememberMapChange();
lmeState.gpsTrailPoints = [];
lmeState.gpsTrail.setLatLngs([]);
lmeState.gpsTrailStartedAt = null;
finishMapChange("Eliminazione traccia GPS");
setInfo("Traccia GPS eliminata.");
return true;
}
function getGpsTrailStats() {
const length = lmeState.gpsTrailPoints.reduce((sum, point, index) => index ? sum + lmeState.map.distance(lmeState.gpsTrailPoints[index - 1], point) : 0, 0);
const durationSeconds = lmeState.gpsTrailStartedAt ? Math.max(0, Math.round((Date.now() - new Date(lmeState.gpsTrailStartedAt).getTime()) / 1000)) : 0;
return { points: lmeState.gpsTrailPoints.length, length, durationSeconds, recording: lmeState.gpsTrailEnabled };
}
function onGpsPosition(position) {
const { latitude, longitude, accuracy } = position.coords;
const latlng = [latitude, longitude];
lmeState.lastPosition = latlng;
lmeState.lastAccuracy = Math.max(0, Number(accuracy) || 0);
const firstLiveFix = !lmeState.hasLiveGpsFix;
lmeState.hasLiveGpsFix = true;
updateGpsPositionLayers(latlng, lmeState.lastAccuracy);
if (firstLiveFix) {
lmeState.map.setView(latlng, 19);
}
if (lmeState.gpsTrailEnabled) {
lmeState.gpsTrailPoints.push(latlng);
lmeState.gpsTrail.setLatLngs(lmeState.gpsTrailPoints);
if (lmeState.gpsTrailPoints.length % 10 === 0) finishMapChange();
}
setText("gpsState", `${latitude.toFixed(6)}, ${longitude.toFixed(6)} - precisione ${Math.round(lmeState.lastAccuracy)} m`);
updateGpsInsideState();
}
function updateGpsPositionLayers(latlng, accuracy) {
let created = false;
if (!lmeState.gpsMarker) {
lmeState.gpsMarker = L.marker(latlng, { pane: "gpsPositionPane", title: "La tua posizione" });
lmeState.gpsMarker.lmeManagerId = "gps-position-marker";
lmeState.gpsMarker.lmeManagerLabel = "Posizione GPS";
lmeState.gpsAccuracy = L.circle(latlng, {
pane: "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]];
updateGpsProximity(point);
const officialMatch = findContainingPolygon(point, lmeState.selectedParcelsLayer.toGeoJSON())
|| findContainingPolygon(point, lmeState.gpsParcelLayer.toGeoJSON());
if (officialMatch) {
const reference = officialMatch.properties?.NATIONALCADASTRALREFERENCE
|| officialMatch.properties?.INSPIREID_LOCALID
|| officialMatch.properties?.LABEL
|| "particella AdE";
setText("parcelState", `Dentro particella AdE: ${reference}`);
return;
}
const match = findContainingPolygon(point, lmeState.imported.toGeoJSON()) || findContainingPolygon(point, lmeState.drawings.toGeoJSON());
if (match) {
const name = match.properties?.name || match.properties?.Name || "area caricata/disegnata";
setText("parcelState", `Dentro: ${name}`);
return;
}
setText(
"parcelState",
lmeState.selectedParcelsLayer.getLayers().length || lmeState.gpsParcelLayer.getLayers().length
? "Fuori dalla particella catastale evidenziata"
: "Fuori dai poligoni caricati");
}
function updateGpsProximity(coordinates) {
if (!lmeState.gpsProximityEnabled || !window.turf) return;
const record = lmeState.managerRecords?.get(lmeState.managerHighlightedObjectId)
|| [...lmeState.managerSelectedObjectIds].map(id => lmeState.managerRecords?.get(id)).find(Boolean);
if (!record?.layer) return;
try {
const feature = record.layer.toGeoJSON();
const boundary = feature.geometry.type.includes("Polygon") ? turf.polygonToLine(feature) : feature;
const lines = boundary.type === "FeatureCollection" ? boundary.features : [boundary];
let nearest = null;
lines.forEach(line => {
const candidate = line.geometry.type.includes("Line") ? turf.nearestPointOnLine(line, turf.point(coordinates), { units: "meters" }) : null;
if (candidate && (!nearest || candidate.properties.dist < nearest.properties.dist)) nearest = candidate;
});
if (!nearest) return;
const distance = nearest.properties.dist;
const bearing = turf.bearing(turf.point(coordinates), nearest);
const direction = bearingToCardinal(bearing);
const inside = feature.geometry.type.includes("Polygon") && turf.booleanPointInPolygon(turf.point(coordinates), feature);
setText("gpsState", `${coordinates[1].toFixed(6)}, ${coordinates[0].toFixed(6)} - bordo ${formatDistance(distance)} ${direction}`);
if (lmeState.gpsBoundaryAlertEnabled && lmeState.lastBoundaryState !== null && inside !== lmeState.lastBoundaryState) {
navigator.vibrate?.([180, 100, 180]);
setInfo(inside ? "Ingresso nell'area selezionata." : "Uscita dall'area selezionata.");
}
lmeState.lastBoundaryState = inside;
} catch { }
}
function bearingToCardinal(value) {
const labels = ["N", "NE", "E", "SE", "S", "SO", "O", "NO"];
return labels[Math.round(((value % 360) + 360) % 360 / 45) % 8];
}
function saveGpsMeasurementPoint() {
if (!lmeState.lastPosition) { setInfo("Attendi una posizione GPS valida."); return; }
rememberMapChange();
const now = new Date();
addDrawingFeature({ type: "Feature", properties: {
name: `Rilievo GPS ${now.toLocaleString("it-IT")}`,
_lmeAccuracy: lmeState.lastAccuracy,
_lmeMeasuredAt: now.toISOString(),
_lmeNotes: `Precisione dichiarata: ${Math.round(lmeState.lastAccuracy)} m`
}, geometry: { type: "Point", coordinates: [lmeState.lastPosition[1], lmeState.lastPosition[0]] } }, true);
refreshLayerManager(); finishMapChange(); setInfo("Punto GPS salvato con precisione, data e ora.");
}
function gmlParcelsToGeoJson(xmlText) {
const xml = new DOMParser().parseFromString(xmlText, "text/xml");
const members = Array.from(xml.getElementsByTagNameNS("*", "member"));
const features = members.map(member => parcelMemberToFeature(member)).filter(Boolean);
return { type: "FeatureCollection", features };
}
function parcelMemberToFeature(member) {
const posList = member.getElementsByTagNameNS("*", "posList")[0]?.textContent?.trim();
if (!posList) {
return null;
}
const values = posList.split(/\s+/).map(Number).filter(value => !Number.isNaN(value));
const ring = [];
for (let index = 0; index < values.length; index += 2) {
ring.push([values[index + 1], values[index]]);
}
const properties = {};
["INSPIREID_LOCALID", "INSPIREID_NAMESPACE", "LABEL", "NATIONALCADASTRALREFERENCE", "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("'", "'");
}