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

5336 lines
229 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const lmeState = {
map: null,
baseLayers: {},
currentBase: null,
currentBaseName: "satellite",
layerOrder: [
"gpsPosition",
"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,
geometryToolsControl: 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,
autoFitSelectedObject: false,
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();
addGeometryToolsControl();
if (!lmeState.resizeBound) {
window.addEventListener("resize", resize);
window.addEventListener("orientationchange", resize);
lmeState.resizeBound = true;
}
lmeState.map.on("click", async event => {
if (event.originalEvent?._lmeDrawingSelected || Date.now() < lmeState.ignoreMapClickUntil) {
return;
}
hideDrawingScopeMenu();
clearManagerHighlight();
const info = await queryFeatureInfo(event.latlng);
if (info) {
L.popup().setLatLng(event.latlng).setContent(info).openOn(lmeState.map);
}
});
lmeState.map.on("moveend", () => {
void updateAzureAttribution();
scheduleMeasurementRefresh();
});
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(`<strong>${escapeHtml(ref)}</strong>`);
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), blocks: true },
{ key: "gpsTrail", label: "Traccia GPS", roots: [lmeState.gpsTrail].filter(Boolean) },
{ key: "gpsParcels", label: "Particella rilevata dal GPS", roots: [lmeState.gpsParcelLayer].filter(Boolean) },
{ key: "selectedParcels", label: "Particelle catastali evidenziate", roots: [lmeState.selectedParcelsLayer].filter(Boolean) },
{ key: "importedSheets", label: "Cornici e fogli importati", roots: [lmeState.importedSheets].filter(Boolean), blocks: true },
{ key: "imported", label: "Progetti e dati importati", roots: [lmeState.imported].filter(Boolean), blocks: true },
{ key: "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._lmeDisplayName
|| properties.name
|| properties.Name
|| properties.NATIONALCADASTRALREFERENCE
|| properties.INSPIREID_LOCALID
|| properties.LABEL
|| properties.label;
if (reference) {
return String(reference);
}
if (layer.lmeManagerLabel) {
return layer.lmeManagerLabel;
}
if (layer instanceof L.Circle) {
return `Cerchio ${index + 1}`;
}
if (layer instanceof L.Polygon) {
return `Area/particella ${index + 1}`;
}
if (layer instanceof L.Polyline) {
return `Linea ${index + 1}`;
}
if (layer instanceof L.Marker) {
return `POI ${index + 1}`;
}
return `Oggetto ${index + 1}`;
}
function managedObjectEditableName(record) {
const properties = record?.layer?.feature?.properties || {};
return properties._lmeDisplayName || properties.name || properties.Name || properties.label || properties.LABEL || "";
}
function managedObjectCanRename(record) {
return ["drawings", "imported", "importedSheets"].includes(record.categoryKey)
&& record.layer?.feature?.properties;
}
async function renameManagedObject(record, index) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (!managedObjectCanRename(record)) {
return;
}
const currentName = String(managedObjectEditableName(record) || managedObjectLabel(record, index)).trim();
const result = await showFormDialog("Rinomina oggetto", [{ name: "name", label: "Nome", value: currentName, maxLength: 120 }], "Rinomina");
if (!result) return;
const requestedName = result.name;
const name = requestedName.trim().slice(0, 120);
if (!name) {
setInfo("Nome non modificato: inserisci un testo.");
return;
}
rememberMapChange();
const properties = record.layer.feature.properties;
properties.name = name;
properties._lmeDisplayName = name;
record.layer.lmeManagerLabel = record.categoryKey === "drawings" && record.layer instanceof L.Marker
? `POI: ${name}`
: null;
updateStoredFeaturePopup(record.layer);
refreshImportedMeasurements();
refreshDrawingMeasurements();
refreshPermanentLabels();
refreshLayerManager();
finishMapChange();
setInfo(`Oggetto rinominato: ${name}.`);
}
function managedCategoryName(category, records) {
const storedName = lmeState.managerCategoryNames[category.key];
const featureName = records
.map(record => record.layer?.feature?.properties?._lmeCategoryName)
.find(Boolean);
const name = String(storedName || featureName || category.label).trim();
if (name !== category.label) {
lmeState.managerCategoryNames[category.key] = name;
}
return name;
}
function managedCategoryCanRename(category) {
return ["drawings", "imported", "importedSheets"].includes(category.key);
}
async function renameManagedCategory(category, records) {
const currentName = managedCategoryName(category, records);
const result = await showFormDialog("Rinomina gruppo", [{ name: "name", label: "Nome gruppo", value: currentName, maxLength: 120 }], "Rinomina");
if (!result) return;
const requestedName = result.name;
const name = requestedName.trim().slice(0, 120);
if (!name) {
setInfo("Nome non modificato: inserisci un testo.");
return;
}
rememberMapChange();
lmeState.managerCategoryNames[category.key] = name;
records.forEach(record => {
if (record.layer?.feature?.properties) {
record.layer.feature.properties._lmeCategoryName = name;
}
});
refreshLayerManager();
finishMapChange();
setInfo(`Gruppo rinominato: ${name}.`);
}
function managedBlockName(source, records) {
return String(records
.map(record => record.layer?.feature?.properties?._lmeGroupName)
.find(Boolean) || source);
}
async function renameManagedBlock(category, source, records) {
const currentName = managedBlockName(source, records);
const result = await showFormDialog("Rinomina gruppo", [{ name: "name", label: "Nome gruppo", value: currentName, maxLength: 120 }], "Rinomina");
if (!result) return;
const requestedName = result.name;
const name = requestedName.trim().slice(0, 120);
if (!name) {
setInfo("Nome non modificato: inserisci un testo.");
return;
}
rememberMapChange();
records.forEach(record => {
if (record.layer?.feature?.properties) {
record.layer.feature.properties._lmeGroupName = name;
}
});
refreshLayerManager();
finishMapChange();
setInfo(`Gruppo rinominato: ${name}.`);
}
function managedCategoryCanRemove(category, records) {
return records.length > 0
&& ["drawings", "imported", "importedSheets", "selectedParcels", "gpsParcels"].includes(category.key);
}
function managedObjectCanRemove(record) {
return ["drawings", "imported", "importedSheets", "selectedParcels", "gpsParcels"].includes(record.categoryKey);
}
function layerManagerRowActions(renameButton, moveButton, removeButton) {
if (!renameButton && !moveButton && !removeButton) {
return "";
}
return `<span class="layer-row-actions">${renameButton}${moveButton}${removeButton}</span>`;
}
const layerManagerPalettes = [
{ surface: "#f0fdf4", parent: "#dcfce7", border: "#86efac", accent: "#166534" },
{ surface: "#eff6ff", parent: "#dbeafe", border: "#93c5fd", accent: "#1d4ed8" },
{ surface: "#fffbeb", parent: "#fef3c7", border: "#fcd34d", accent: "#92400e" },
{ surface: "#fff1f2", parent: "#ffe4e6", border: "#fda4af", accent: "#be123c" },
{ surface: "#f5f3ff", parent: "#ede9fe", border: "#c4b5fd", accent: "#6d28d9" },
{ surface: "#ecfeff", parent: "#cffafe", border: "#67e8f9", accent: "#0e7490" },
{ surface: "#f7fee7", parent: "#ecfccb", border: "#bef264", accent: "#3f6212" }
];
function applyLayerManagerPalette(row, key) {
const hash = [...String(key)].reduce((value, character) =>
((value * 31) + character.charCodeAt(0)) >>> 0, 0);
const palette = layerManagerPalettes[hash % layerManagerPalettes.length];
row.style.setProperty("--layer-group-surface", palette.surface);
row.style.setProperty("--layer-group-parent", palette.parent);
row.style.setProperty("--layer-group-border", palette.border);
row.style.setProperty("--layer-group-accent", palette.accent);
}
function clearManagedObjectState(id) {
lmeState.managerHiddenObjects.delete(id);
lmeState.managerHiddenObjectIds.delete(id);
lmeState.managerHiddenMeasurementSummaryIds.delete(id);
lmeState.managerHiddenMeasurementSideIds.delete(id);
Object.keys(lmeState.managerObjectOrder).forEach(categoryKey => {
lmeState.managerObjectOrder[categoryKey] = lmeState.managerObjectOrder[categoryKey]
.filter(objectId => objectId !== id);
});
}
function removeManagedRecords(records, subject) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (!records.length) {
return;
}
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) {
setInfo("Operazione annullata: sblocca prima tutti gli oggetti coinvolti.");
return;
}
const message = records.length === 1
? `Rimuovere ${subject} dal progetto? Non verra incluso nel prossimo salvataggio.`
: `Rimuovere ${subject} e tutti i suoi ${records.length} oggetti dal progetto? Non verranno inclusi nel prossimo salvataggio.`;
if (!window.confirm(message)) {
return;
}
rememberMapChange();
lmeState.trash.push({
removedAt: new Date().toISOString(),
subject,
items: records.map(record => ({
categoryKey: record.categoryKey,
source: record.source,
feature: record.layer.toGeoJSON()
}))
});
if (lmeState.trash.length > 30) lmeState.trash.shift();
const removedIds = new Set(records.map(record => record.id));
const categoryKeys = new Set(records.map(record => record.categoryKey));
records.forEach(record => {
if (record.parent?.hasLayer?.(record.layer)) {
record.parent.removeLayer(record.layer);
}
clearManagedObjectState(record.id);
});
categoryKeys.forEach(categoryKey => {
const order = lmeState.managerObjectOrder[categoryKey] || [];
lmeState.managerObjectOrder[categoryKey] = order.filter(id => !removedIds.has(id));
const category = layerManagerCategories().find(item => item.key === categoryKey);
if (category && managedRecordsForCategory(category).length === 0) {
lmeState.managerRemovedCategories.add(categoryKey);
delete lmeState.managerCategoryNames[categoryKey];
}
});
clearManagerHighlight();
refreshImportedMeasurements();
refreshDrawingMeasurements();
updateGpsInsideState();
refreshLayerManager();
finishMapChange(`Rimozione: ${subject}`);
setInfo(`${records.length === 1 ? "Oggetto rimosso" : `${records.length} oggetti rimossi`} dal progetto.`);
}
function managedMoveDestinations() {
return layerManagerCategories()
.filter(category => ["drawings", "imported"].includes(category.key))
.map(category => {
const records = managedRecordsForCategory(category);
const groups = new Map();
records.forEach(record => {
if (!groups.has(record.source)) {
groups.set(record.source, managedBlockName(record.source, [record]));
}
});
if (!groups.size) {
groups.set("Progetto corrente", "Gruppo principale");
}
return {
key: category.key,
name: managedCategoryName(category, records),
groups: [...groups].map(([source, name]) => ({ source, name }))
};
});
}
function showMoveManagedRecordsDialog(records, subject) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (!records.length || records.some(record => !["drawings", "imported"].includes(record.categoryKey))) {
setInfo("Questo oggetto o gruppo non puo essere spostato.");
return;
}
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) {
setInfo("Sblocca prima tutti gli oggetti da spostare.");
return;
}
const destinations = managedMoveDestinations();
const categoryOptions = destinations.map(destination =>
`<option value="${escapeHtml(destination.key)}">${escapeHtml(destination.name)}</option>`).join("");
const dialog = showAppDialog("Sposta nel gruppo", `<form class="lme-edit-form move-group-form"><p>Cambia l'appartenenza di ${escapeHtml(subject)} senza modificarne la posizione sulla mappa.</p><label>Categoria<select name="category">${categoryOptions}</select></label><label>Gruppo esistente<select name="group"></select></label><label>Oppure crea un nuovo gruppo<input name="newGroup" type="text" maxlength="120" placeholder="Nome del nuovo gruppo"></label><div class="dialog-command-row"><button type="button" data-cancel>Annulla</button><button type="submit" class="primary-command">Sposta nel gruppo</button></div></form>`);
const form = dialog.querySelector("form");
const categorySelect = form.elements.category;
const groupSelect = form.elements.group;
const newGroupInput = form.elements.newGroup;
const currentCategory = records.every(record => record.categoryKey === records[0].categoryKey)
? records[0].categoryKey
: "drawings";
categorySelect.value = currentCategory;
const refreshGroups = () => {
const destination = destinations.find(item => item.key === categorySelect.value);
groupSelect.innerHTML = (destination?.groups || []).map(group =>
`<option value="${escapeHtml(group.source)}">${escapeHtml(group.name)}</option>`).join("");
const currentSource = records.every(record => record.source === records[0].source)
? records[0].source
: "";
if (destination?.key === currentCategory && currentSource
&& destination.groups.some(group => group.source === currentSource)) {
groupSelect.value = currentSource;
}
};
categorySelect.addEventListener("change", refreshGroups);
refreshGroups();
dialog.querySelector("[data-cancel]").addEventListener("click", () => dialog.remove());
form.addEventListener("submit", event => {
event.preventDefault();
const destination = destinations.find(item => item.key === categorySelect.value);
if (!destination) return;
const requestedName = newGroupInput.value.trim().slice(0, 120);
const existingByName = requestedName
? destination.groups.find(group => group.name.toLocaleLowerCase("it") === requestedName.toLocaleLowerCase("it"))
: null;
const selectedGroup = destination.groups.find(group => group.source === groupSelect.value);
const target = existingByName || (requestedName
? { source: `Gruppo ${requestedName}`, name: requestedName }
: selectedGroup);
if (!target) {
setInfo("Scegli un gruppo di destinazione o inserisci il nome di un nuovo gruppo.");
return;
}
dialog.remove();
moveManagedRecordsToGroup(records, destination.key, target.source, target.name);
});
}
function moveManagedRecordsToGroup(records, targetCategory, targetSource, targetGroupName) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
const movable = records.filter(record => ["drawings", "imported"].includes(record.categoryKey));
if (!movable.length || !["drawings", "imported"].includes(targetCategory)) {
setInfo("Questo gruppo non puo essere spostato.");
return;
}
if (movable.some(record => record.layer?.feature?.properties?._lmeLocked)) {
setInfo("Sblocca il gruppo prima di spostarlo.");
return;
}
rememberMapChange();
const features = movable.map(record => {
const feature = record.layer.toGeoJSON();
feature.properties = {
...(feature.properties || {}),
_lmeSource: targetSource,
_lmeGroupName: targetGroupName
};
delete feature.properties._lmeManagerId;
return feature;
});
movable.forEach(record => {
record.parent?.removeLayer?.(record.layer);
clearManagedObjectState(record.id);
lmeState.managerSelectedObjectIds.delete(record.id);
});
if (targetCategory === "drawings") {
features.forEach(feature => addDrawingFeature(feature));
} else {
addImportedFeatures({ type: "FeatureCollection", features });
}
refreshImportedMeasurements();
refreshDrawingMeasurements();
refreshLayerManager();
const categoryName = targetCategory === "drawings" ? "Disegni e POI" : "Progetti e dati importati";
finishMapChange(`Spostati ${features.length} oggetti in ${categoryName} > ${targetGroupName}.`);
setInfo(`${features.length} oggetti spostati in ${categoryName} > ${targetGroupName}.`);
}
function managedObjectDetails(layer) {
const details = [];
const properties = layer?.feature?.properties || {};
if (properties.name || properties.Name) {
details.push(`Nome: ${properties.name || properties.Name}`);
}
if (layer instanceof L.Circle) {
const radius = layer.getRadius();
details.push(`Raggio: ${formatDistance(radius)}`);
details.push(`Superficie: ${formatArea(Math.PI * radius * radius)}`);
details.push(`Circonferenza: ${formatDistance(2 * Math.PI * radius)}`);
} else if (layer instanceof L.Polygon) {
polygonLatLngGroups(layer.getLatLngs()).forEach((polygon, polygonIndex) => {
const outer = polygon[0] || [];
const area = polygon.reduce((sum, ring, ringIndex) => {
const ringArea = Math.abs(L.GeometryUtil.geodesicArea(ring));
return sum + (ringIndex === 0 ? ringArea : -ringArea);
}, 0);
details.push(`${polygonIndex ? `Area ${polygonIndex + 1}` : "Superficie"}: ${formatArea(Math.abs(area))}`);
details.push(`${polygonIndex ? `Perimetro ${polygonIndex + 1}` : "Perimetro"}: ${formatDistance(latLngPathLength(outer, true))}`);
outer.forEach((point, index) => {
const next = outer[(index + 1) % outer.length];
if (next && !point.equals(next)) {
details.push(`Lato ${index + 1}: ${formatDistance(lmeState.map.distance(point, next))}`);
}
});
});
} else if (layer instanceof L.Polyline) {
const lines = lineLatLngGroups(layer.getLatLngs());
details.push(`Lunghezza: ${formatDistance(lines.reduce((sum, line) => sum + latLngPathLength(line, false), 0))}`);
} else if (typeof layer.getLatLng === "function") {
const point = layer.getLatLng();
details.push(`Coordinate: ${point.lat.toFixed(6)}, ${point.lng.toFixed(6)}`);
}
return details;
}
function refreshLayerManager() {
const container = document.getElementById("layerManagerTree");
if (!container || !lmeState.map) {
return;
}
captureLayerManagerExpansion(container);
const scrollHost = container.closest(".map-layer-panel");
const scrollTop = scrollHost?.scrollTop || 0;
applyStoredLayerManagerVisibility();
container.innerHTML = "";
lmeState.managerRecords = new Map();
container.appendChild(renderLayerSelectionToolbar());
const categoriesByKey = new Map(layerManagerCategories().map(category => [category.key, category]));
lmeState.layerOrder.forEach(categoryKey => {
const category = categoriesByKey.get(categoryKey);
if (!category) {
return;
}
const records = managedRecordsForCategory(category);
if (records.length) {
lmeState.managerRemovedCategories.delete(category.key);
} else if (lmeState.managerRemovedCategories.has(category.key)) {
return;
}
const savedOrder = lmeState.managerObjectOrder[category.key] || [];
const positions = new Map(savedOrder.map((id, index) => [id, index]));
records.sort((left, right) =>
(positions.get(left.id) ?? Number.MAX_SAFE_INTEGER) - (positions.get(right.id) ?? Number.MAX_SAFE_INTEGER));
records.forEach(record => lmeState.managerRecords.set(record.id, record));
const rendered = renderLayerManagerCategory(category, records);
const search = lmeState.managerSearchText.trim().toLocaleLowerCase("it");
if (search && !records.some((record, index) => `${managedObjectLabel(record, index)} ${record.source}`.toLocaleLowerCase("it").includes(search))
&& !managedCategoryName(category, records).toLocaleLowerCase("it").includes(search)) {
rendered.hidden = true;
}
container.appendChild(rendered);
});
applyManagedVisualOrder(container);
bindLayerManagerDrag(container);
bindLayerManagerMoveButtons(container);
if (lmeState.managerHighlightedObjectId) {
window.requestAnimationFrame(() => revealManagedRecordInLayerManager(lmeState.managerHighlightedObjectId));
}
if (scrollHost) {
scrollHost.scrollTop = scrollTop;
window.requestAnimationFrame(() => {
scrollHost.scrollTop = scrollTop;
});
}
}
function renderLayerManagerCategory(category, records) {
const group = document.createElement("details");
group.className = "layer-manager-group";
group.classList.toggle("layer-manager-selection-path", records.some(record =>
record.id === lmeState.managerHighlightedObjectId || lmeState.managerSelectedObjectIds.has(record.id)));
group.dataset.dragType = "category";
group.dataset.layerDepth = "0";
group.dataset.categoryKey = category.key;
applyLayerManagerPalette(group, `category:${category.key}`);
bindLayerManagerExpansion(group, `category:${category.key}`);
const categoryName = managedCategoryName(category, records);
const renameButton = managedCategoryCanRename(category)
? `<button type="button" class="layer-rename-button" aria-label="Rinomina gruppo" title="Rinomina">Rinomina</button>`
: "";
const removeButton = managedCategoryCanRemove(category, records)
? `<button type="button" class="layer-remove-button" aria-label="Rimuovi gruppo dal progetto" title="Rimuovi dal progetto">Rimuovi</button>`
: "";
const moveButton = records.length && ["drawings", "imported"].includes(category.key)
? `<button type="button" class="layer-membership-button" aria-label="Sposta gruppo in un altro gruppo" title="Sposta nel gruppo">Sposta nel gruppo</button>`
: "";
const rowActions = layerManagerRowActions(renameButton, moveButton, removeButton);
group.classList.toggle("layer-manager-with-actions", Boolean(rowActions));
const visibleCount = records.filter(record => !lmeState.managerHiddenObjectIds.has(record.id)).length;
const categoryVisible = !lmeState.managerHiddenCategories.has(category.key)
&& (!category.roots.length || category.roots.some(layer => lmeState.map.hasLayer(layer)));
const visible = categoryVisible;
const categoryLocked = records.length > 0 && records.every(record => Boolean(record.layer?.feature?.properties?._lmeLocked));
group.innerHTML = `<summary>${layerExpandButton(group.open, categoryName)}<button type="button" class="layer-drag-handle" aria-label="Trascina gruppo">☰</button><input type="checkbox" ${visible ? "checked" : ""} aria-label="Mostra ${escapeHtml(categoryName)}"><span class="layer-manager-title">${escapeHtml(categoryName)}</span><button type="button" class="layer-inline-lock ${categoryLocked ? "is-locked" : ""}" aria-label="${categoryLocked ? "Sblocca" : "Blocca"} gruppo" title="${categoryLocked ? "Sblocca" : "Blocca"}">${categoryLocked ? "🔒" : "🔓"}</button>${rowActions}${layerManagerMoveButtons("gruppo")}<span class="layer-manager-count">${records.length}</span></summary><div class="layer-manager-children"></div>`;
bindLayerManagerToggleButton(group);
const checkbox = group.querySelector("input");
checkbox.indeterminate = categoryVisible && records.length > 0 && visibleCount < records.length;
checkbox.addEventListener("click", event => event.stopPropagation());
checkbox.addEventListener("change", () => setManagedCategoryVisible(category, checkbox.checked));
group.querySelector(".layer-inline-lock")?.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
updateManagedProperties(records, properties => { properties._lmeLocked = !categoryLocked; });
});
group.querySelector(".layer-rename-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
renameManagedCategory(category, records);
});
group.querySelector(".layer-remove-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
removeManagedRecords(records, `il gruppo '${categoryName}'`);
});
group.querySelector(".layer-membership-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
showMoveManagedRecordsDialog(records, `il gruppo '${categoryName}'`);
});
bindManagedParentHighlight(group, `category:${category.key}`, records);
const children = group.querySelector(".layer-manager-children");
if (records.length) children.appendChild(renderManagedGroupTools(records));
if (category.blocks) {
const blocks = new Map();
records.forEach(record => {
const list = blocks.get(record.source) || [];
list.push(record);
blocks.set(record.source, list);
});
blocks.forEach((blockRecords, source) => children.appendChild(renderLayerManagerBlock(category, source, blockRecords)));
} else {
records.forEach((record, index) => children.appendChild(renderLayerManagerObject(record, index)));
}
return group;
}
function renderLayerManagerBlock(category, source, records) {
const block = document.createElement("details");
block.className = "layer-manager-block";
block.classList.toggle("layer-manager-selection-path", records.some(record =>
record.id === lmeState.managerHighlightedObjectId || lmeState.managerSelectedObjectIds.has(record.id)));
block.dataset.dragType = "block";
block.dataset.layerDepth = "1";
block.dataset.categoryKey = category.key;
block.dataset.blockKey = source;
applyLayerManagerPalette(block, `block:${category.key}:${source}`);
bindLayerManagerExpansion(block, `block:${category.key}:${source}`);
const visibleCount = records.filter(record => !lmeState.managerHiddenObjectIds.has(record.id)).length;
const visible = visibleCount === records.length;
const blockName = managedBlockName(source, records);
const rowActions = layerManagerRowActions(
`<button type="button" class="layer-rename-button" aria-label="Rinomina gruppo" title="Rinomina">Rinomina</button>`,
`<button type="button" class="layer-membership-button" aria-label="Sposta gruppo in un altro gruppo" title="Sposta nel gruppo">Sposta nel gruppo</button>`,
`<button type="button" class="layer-remove-button" aria-label="Rimuovi gruppo dal progetto" title="Rimuovi dal progetto">Rimuovi</button>`);
block.classList.add("layer-manager-with-actions");
const blockLocked = records.length > 0 && records.every(record => Boolean(record.layer?.feature?.properties?._lmeLocked));
block.innerHTML = `<summary>${layerExpandButton(block.open, blockName)}<button type="button" class="layer-drag-handle" aria-label="Trascina blocco">☰</button><input type="checkbox" ${visible ? "checked" : ""} aria-label="Mostra ${escapeHtml(blockName)}"><span class="layer-manager-title">${escapeHtml(blockName)}</span><button type="button" class="layer-inline-lock ${blockLocked ? "is-locked" : ""}" aria-label="${blockLocked ? "Sblocca" : "Blocca"} gruppo" title="${blockLocked ? "Sblocca" : "Blocca"}">${blockLocked ? "🔒" : "🔓"}</button>${rowActions}${layerManagerMoveButtons("blocco")}<span class="layer-manager-count">${records.length}</span></summary><div class="layer-manager-children"></div>`;
bindLayerManagerToggleButton(block);
const checkbox = block.querySelector("input");
checkbox.indeterminate = visibleCount > 0 && !visible;
checkbox.addEventListener("click", event => event.stopPropagation());
checkbox.addEventListener("change", () => {
records.forEach(record => setManagedObjectVisible(record, checkbox.checked, false));
refreshLayerManager();
});
block.querySelector(".layer-inline-lock")?.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
updateManagedProperties(records, properties => { properties._lmeLocked = !blockLocked; });
});
block.querySelector(".layer-rename-button").addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
renameManagedBlock(category, source, records);
});
block.querySelector(".layer-remove-button").addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
removeManagedRecords(records, `il gruppo '${blockName}'`);
});
block.querySelector(".layer-membership-button").addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
showMoveManagedRecordsDialog(records, `il gruppo '${blockName}'`);
});
bindManagedParentHighlight(block, `block:${category.key}:${source}`, records);
const children = block.querySelector(".layer-manager-children");
children.appendChild(renderManagedGroupTools(records));
records.forEach((record, index) => children.appendChild(renderLayerManagerObject(record, index)));
return block;
}
function renderLayerManagerObject(record, index) {
const object = document.createElement("details");
object.className = "layer-manager-object";
if (record.id === lmeState.managerHighlightedObjectId) {
object.classList.add("layer-manager-object-selected");
}
object.dataset.dragType = "object";
object.dataset.layerDepth = "2";
object.dataset.objectId = record.id;
bindLayerManagerExpansion(object, `object:${record.id}`);
const visible = !lmeState.managerHiddenObjectIds.has(record.id);
const renameButton = managedObjectCanRename(record)
? `<button type="button" class="layer-rename-button" aria-label="Rinomina oggetto" title="Rinomina">Rinomina</button>`
: "";
const removeButton = managedObjectCanRemove(record)
? `<button type="button" class="layer-remove-button" aria-label="Rimuovi oggetto dal progetto" title="Rimuovi dal progetto">Rimuovi</button>`
: "";
const moveButton = ["drawings", "imported"].includes(record.categoryKey)
? `<button type="button" class="layer-membership-button" aria-label="Sposta oggetto in un altro gruppo" title="Sposta nel gruppo">Sposta nel gruppo</button>`
: "";
const rowActions = layerManagerRowActions(renameButton, moveButton, removeButton);
object.classList.toggle("layer-manager-with-actions", Boolean(rowActions));
const objectName = managedObjectLabel(record, index);
const locked = Boolean(record.layer?.feature?.properties?._lmeLocked);
object.innerHTML = `<summary>${layerExpandButton(object.open, objectName)}<button type="button" class="layer-drag-handle" aria-label="Trascina oggetto">☰</button><input type="checkbox" ${visible ? "checked" : ""} aria-label="Mostra oggetto"><span class="layer-manager-title">${escapeHtml(objectName)}</span><button type="button" class="layer-inline-lock ${locked ? "is-locked" : ""}" aria-label="${locked ? "Sblocca" : "Blocca"} oggetto" title="${locked ? "Sblocca" : "Blocca"}">${locked ? "🔒" : "🔓"}</button>${rowActions}${layerManagerMoveButtons("oggetto")}</summary><div class="layer-manager-details"></div>`;
bindLayerManagerToggleButton(object);
const checkbox = object.querySelector("input");
checkbox.addEventListener("click", event => event.stopPropagation());
checkbox.addEventListener("change", () => setManagedObjectVisible(record, checkbox.checked, true));
object.querySelector(".layer-rename-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
renameManagedObject(record, index);
});
object.querySelector(".layer-remove-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
removeManagedRecords([record], `'${objectName}'`);
});
object.querySelector(".layer-membership-button")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
showMoveManagedRecordsDialog([record], `'${objectName}'`);
});
object.querySelector(".layer-inline-lock")?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
updateManagedProperties([record], value => { value._lmeLocked = !locked; });
});
object.querySelector(".layer-manager-title").addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
highlightManagedObject(record);
});
const details = object.querySelector(".layer-manager-details");
details.appendChild(renderLayerManagerVisibilityOption(
geometryVisibilityLabel(record.layer),
visible,
checked => setManagedObjectVisible(record, checked, true)));
if (supportsManagedMeasurements(record)) {
managedMeasurementKinds(record).forEach(kind => {
const measurementsVisible = managedMeasurementsGloballyVisible(record.categoryKey)
&& !managedMeasurementHiddenSet(kind.key).has(record.id);
details.appendChild(renderLayerManagerVisibilityOption(
kind.label,
measurementsVisible,
checked => setManagedMeasurementKindVisible(record, kind.key, checked)));
});
}
const rows = managedObjectDetails(record.layer);
const info = document.createElement("div");
info.className = "layer-manager-info";
info.innerHTML = rows.length
? rows.map(value => `<span>${escapeHtml(value)}</span>`).join("")
: "<span>Nessuna misura disponibile</span>";
details.appendChild(info);
details.appendChild(renderManagedObjectTools(record));
return object;
}
function renderLayerSelectionToolbar() {
const selectedCount = lmeState.managerSelectedObjectIds.size;
const toolbar = document.createElement("section");
toolbar.className = "layer-selection-toolbar";
toolbar.innerHTML = `<label>Cerca oggetti<input type="search" value="${escapeHtml(lmeState.managerSearchText)}" placeholder="Nome o gruppo"></label>
<div class="layer-selection-summary"><strong>${selectedCount} selezionati</strong><button type="button" data-action="clear">Deseleziona</button></div>
<div class="layer-batch-actions">
<button type="button" data-action="duplicate">Duplica</button><button type="button" data-action="move">Sposta geometria</button>
<button type="button" data-action="buffer">Buffer</button><button type="button" data-action="union">Unisci</button>
<button type="button" data-action="split">Dividi</button><button type="button" data-action="delete" class="danger-command">Rimuovi</button>
</div>`;
const search = toolbar.querySelector("input");
search.addEventListener("input", () => {
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 = `<strong>Azioni gruppo</strong><span class="layer-group-totals">${escapeHtml(managedGroupTotals(records))}</span><div class="layer-style-controls">
<label>Colore<input type="color" value="${normalizedColor(properties._lmeColor, "#16806a")}"></label>
<label>Spessore<input type="range" min="1" max="10" step="1" value="${Number(properties._lmeWeight) || 3}"></label>
<label>Opacita<input type="range" min="0.1" max="1" step="0.1" value="${Number(properties._lmeOpacity) || 1}"></label></div>
<div class="layer-compact-actions"><button type="button" data-group-action="select">Seleziona tutti</button><button type="button" data-group-action="lock">Blocca/sblocca</button><button type="button" data-group-action="labels">Etichette</button></div>`;
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 = `<label class="layer-manager-suboption"><input type="checkbox" data-field="selected" ${selected ? "checked" : ""}><span>Seleziona per azioni multiple</span></label>
<label class="layer-manager-suboption"><input type="checkbox" data-field="locked" ${locked ? "checked" : ""}><span>Blocca modifiche e rimozione</span></label>
<label class="layer-manager-suboption"><input type="checkbox" data-field="label" ${properties._lmeLabelVisible ? "checked" : ""}><span>Etichetta permanente sulla mappa</span></label>
<div class="layer-style-controls"><label>Linea<input type="color" data-field="color" value="${normalizedColor(properties._lmeColor, "#f97316")}"></label>
<label>Riempimento<input type="color" data-field="fillColor" value="${normalizedColor(properties._lmeFillColor, "#fb923c")}"></label>
<label>Spessore<input type="range" data-field="weight" min="1" max="10" step="1" value="${Number(properties._lmeWeight) || 3}"></label>
<label>Opacita<input type="range" data-field="opacity" min="0.1" max="1" step="0.1" value="${Number(properties._lmeOpacity) || 1}"></label>
<label>Riempimento %<input type="range" data-field="fillOpacity" min="0" max="0.8" step="0.05" value="${Number.isFinite(Number(properties._lmeFillOpacity)) ? Number(properties._lmeFillOpacity) : 0.2}"></label>
<label>Tratto<select data-field="dash"><option value="">Continuo</option><option value="8 6" ${properties._lmeDash === "8 6" ? "selected" : ""}>Tratteggiato</option><option value="2 6" ${properties._lmeDash === "2 6" ? "selected" : ""}>Puntinato</option></select></label></div>
<label>Note<textarea data-field="notes" rows="2" maxlength="1000" placeholder="Note tecniche">${escapeHtml(properties._lmeNotes || "")}</textarea></label>
<label class="layer-photo-picker">Foto<input type="file" data-field="photo" accept="image/*"></label>
${properties._lmePhoto ? `<img class="layer-photo-preview" src="${properties._lmePhoto}" alt="Foto allegata"><button type="button" data-action="remove-photo">Rimuovi foto</button>` : ""}
<div class="layer-compact-actions"><button type="button" data-action="attributes">Attributi</button><button type="button" data-action="duplicate">Duplica</button><button type="button" data-action="numeric">Coordinate</button><button type="button" data-action="inspect">WGS84 / UTM</button><button type="button" data-action="move">Sposta geometria</button><button type="button" data-action="buffer">Buffer</button></div>`;
tools.querySelector("[data-field='selected']").addEventListener("change", event => {
if (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 entries = Object.entries(properties)
.filter(([key, value]) => !key.startsWith("_lme") && !reserved.has(key) && ["string", "number", "boolean"].includes(typeof value));
const dialog = showAppDialog("Attributi oggetto", `<div class="attribute-editor"><p>Ogni attributo viene salvato nel progetto e negli export GeoJSON, KML e KMZ.</p><div class="attribute-editor-rows"></div><button type="button" data-add-attribute>+ Aggiungi attributo</button><div class="dialog-command-row"><button type="button" data-cancel>Annulla</button><button type="button" class="primary-command" data-save-attributes>Salva attributi</button></div></div>`);
const rows = dialog.querySelector(".attribute-editor-rows");
const addRow = (key = "", value = "") => {
const row = document.createElement("div");
row.className = "attribute-editor-row";
row.innerHTML = `<label>Nome<input type="text" maxlength="60" value="${escapeHtml(key)}" placeholder="Es. proprietario"></label><label>Valore<textarea rows="3" maxlength="4000" placeholder="Testo anche su piu righe">${escapeHtml(String(value))}</textarea></label><button type="button" title="Rimuovi attributo" aria-label="Rimuovi attributo">×</button>`;
row.querySelector("button").addEventListener("click", () => row.remove());
rows.appendChild(row);
};
entries.forEach(([key, value]) => addRow(key, value));
if (!entries.length) addRow();
dialog.querySelector("[data-add-attribute]").addEventListener("click", () => addRow());
dialog.querySelector("[data-cancel]").addEventListener("click", () => dialog.remove());
dialog.querySelector("[data-save-attributes]").addEventListener("click", () => {
const parsed = {};
rows.querySelectorAll(".attribute-editor-row").forEach(row => {
const key = row.querySelector("input").value.trim().replace(/[^a-zA-Z0-9_ -]/g, "_").slice(0, 60);
const value = row.querySelector("textarea").value.slice(0, 4000);
if (key) parsed[key] = value;
});
updateManagedProperties([record], target => {
Object.keys(target).filter(key => !key.startsWith("_lme") && !reserved.has(key)).forEach(key => delete target[key]);
Object.assign(target, parsed);
});
setInfo(`${Object.keys(parsed).length} attributi aggiornati.`);
dialog.remove();
});
}
function utmFromLatLng(latitude, longitude) {
const zone = Math.floor((longitude + 180) / 6) + 1;
const central = (zone - 1) * 6 - 180 + 3;
const a = 6378137; const eccSquared = 0.00669438; const k0 = 0.9996;
const latRad = latitude * Math.PI / 180; const lonRad = longitude * Math.PI / 180; const lonOriginRad = central * Math.PI / 180;
const eccPrimeSquared = eccSquared / (1 - eccSquared);
const n = a / Math.sqrt(1 - eccSquared * Math.sin(latRad) ** 2);
const t = Math.tan(latRad) ** 2; const c = eccPrimeSquared * Math.cos(latRad) ** 2;
const aa = Math.cos(latRad) * (lonRad - lonOriginRad);
const m = a * ((1 - eccSquared / 4 - 3 * eccSquared ** 2 / 64 - 5 * eccSquared ** 3 / 256) * latRad
- (3 * eccSquared / 8 + 3 * eccSquared ** 2 / 32 + 45 * eccSquared ** 3 / 1024) * Math.sin(2 * latRad)
+ (15 * eccSquared ** 2 / 256 + 45 * eccSquared ** 3 / 1024) * Math.sin(4 * latRad)
- (35 * eccSquared ** 3 / 3072) * Math.sin(6 * latRad));
const easting = k0 * n * (aa + (1 - t + c) * aa ** 3 / 6 + (5 - 18 * t + t ** 2 + 72 * c - 58 * eccPrimeSquared) * aa ** 5 / 120) + 500000;
let northing = k0 * (m + n * Math.tan(latRad) * (aa ** 2 / 2 + (5 - t + 9 * c + 4 * c ** 2) * aa ** 4 / 24
+ (61 - 58 * t + t ** 2 + 600 * c - 330 * eccPrimeSquared) * aa ** 6 / 720));
if (latitude < 0) northing += 10000000;
return { zone, band: latitude >= 0 ? "N" : "S", easting, northing };
}
function recordCenter(record) {
if (typeof record?.layer?.getBounds === "function" && record.layer.getBounds().isValid()) return record.layer.getBounds().getCenter();
return record?.layer?.getLatLng?.() || null;
}
function showCoordinateInspectorForRecord(record) {
const point = recordCenter(record);
if (!point) { setInfo("Coordinate non disponibili per questo oggetto."); return; }
showCoordinateDialog(point, managedObjectEditableName(record) || "Oggetto selezionato");
}
function showCoordinateInspector() {
const records = [...lmeState.managerSelectedObjectIds].map(id => lmeState.managerRecords?.get(id)).filter(Boolean);
const highlighted = lmeState.managerRecords?.get(lmeState.managerHighlightedObjectId);
const record = highlighted || records[0];
const point = record ? recordCenter(record) : lmeState.map.getCenter();
showCoordinateDialog(point, record ? managedObjectEditableName(record) || "Oggetto selezionato" : "Centro mappa");
}
function showCoordinateDialog(point, title) {
const utm = utmFromLatLng(point.lat, point.lng);
const text = `WGS84: ${point.lat.toFixed(7)}, ${point.lng.toFixed(7)}\nUTM ${utm.zone}${utm.band}: E ${utm.easting.toFixed(2)} m, N ${utm.northing.toFixed(2)} m`;
showAppDialog(title, `<div class="coordinate-result"><strong>WGS84</strong><span>${point.lat.toFixed(7)}, ${point.lng.toFixed(7)}</span><strong>UTM zona ${utm.zone}${utm.band}</strong><span>E ${utm.easting.toFixed(2)} m<br>N ${utm.northing.toFixed(2)} m</span><button type="button" data-copy-coordinate>Copia coordinate</button></div>`);
document.querySelector("[data-copy-coordinate]")?.addEventListener("click", async () => {
await navigator.clipboard?.writeText(text);
setInfo("Coordinate copiate.");
});
}
function showAppDialog(title, bodyHtml) {
document.getElementById("lmeAppDialog")?.remove();
const overlay = document.createElement("div");
overlay.id = "lmeAppDialog";
overlay.className = "lme-app-dialog";
overlay.innerHTML = `<section role="dialog" aria-modal="true"><header><strong>${escapeHtml(title)}</strong><button type="button" aria-label="Chiudi">×</button></header><div class="lme-app-dialog-body">${bodyHtml}</div></section>`;
overlay.querySelector("header button").addEventListener("click", () => overlay.remove());
overlay.addEventListener("click", event => { if (event.target === overlay) overlay.remove(); });
document.querySelector(".map-app")?.appendChild(overlay);
return overlay;
}
function showFormDialog(title, fields, submitLabel = "Salva") {
return new Promise(resolve => {
const fieldHtml = fields.map(field => {
const common = `name="${escapeHtml(field.name)}" ${field.required === false ? "" : "required"} ${field.maxLength ? `maxlength="${field.maxLength}"` : ""}`;
const control = field.type === "textarea"
? `<textarea ${common} rows="${field.rows || 5}">${escapeHtml(field.value ?? "")}</textarea>`
: `<input ${common} type="${field.type || "text"}" value="${escapeHtml(field.value ?? "")}" ${field.step ? `step="${field.step}"` : ""}>`;
return `<label>${escapeHtml(field.label)}${control}${field.help ? `<small>${escapeHtml(field.help)}</small>` : ""}</label>`;
}).join("");
const dialog = showAppDialog(title, `<form class="lme-edit-form">${fieldHtml}<div class="dialog-command-row"><button type="button" data-cancel>Annulla</button><button type="submit" class="primary-command">${escapeHtml(submitLabel)}</button></div></form>`);
const form = dialog.querySelector("form");
let settled = false;
const finish = value => { if (settled) return; settled = true; resolve(value); dialog.remove(); };
dialog.querySelector("header button").addEventListener("click", () => finish(null), { capture: true });
dialog.querySelector("[data-cancel]").addEventListener("click", () => finish(null));
dialog.addEventListener("click", event => { if (event.target === dialog) finish(null); }, { capture: true });
form.addEventListener("submit", event => {
event.preventDefault();
const values = {};
fields.forEach(field => { values[field.name] = form.elements[field.name]?.value ?? ""; });
finish(values);
});
form.elements[fields[0]?.name]?.focus();
});
}
function showObjectTable() {
const records = layerManagerCategories().flatMap(category => managedRecordsForCategory(category));
lmeState.managerRecords = new Map(records.map(record => [record.id, record]));
const body = records.map((record, index) => {
const properties = record.layer?.feature?.properties || {};
const details = managedObjectDetails(record.layer);
const selected = lmeState.managerSelectedObjectIds.has(record.id);
const locked = Boolean(properties._lmeLocked);
return `<tr data-object-id="${escapeHtml(record.id)}"><td><input type="checkbox" data-select="${escapeHtml(record.id)}" ${selected ? "checked" : ""} aria-label="Seleziona ${escapeHtml(managedObjectLabel(record, index))}"></td><td><input type="checkbox" data-lock="${escapeHtml(record.id)}" ${locked ? "checked" : ""} ${lmeState.readOnly ? "disabled" : ""} aria-label="Blocca ${escapeHtml(managedObjectLabel(record, index))}"></td><td><button type="button" data-locate="${escapeHtml(record.id)}" title="Mostra sulla mappa" aria-label="Mostra sulla mappa">⌖</button></td><td>${escapeHtml(managedObjectLabel(record, index))}</td><td>${escapeHtml(managedCategoryName(layerManagerCategories().find(item => item.key === record.categoryKey) || { key: record.categoryKey, label: record.categoryKey }, [record]))}</td><td>${escapeHtml(properties.foglio || properties.FOGLIO || "")}</td><td>${escapeHtml(properties.particella || properties.PARTICELLA || properties.LABEL || "")}</td><td>${escapeHtml(details.slice(0, 2).join(" | "))}</td></tr>`;
}).join("");
const dialog = showAppDialog("Elenco oggetti e particelle", `<label class="object-table-search">Filtra<input type="search" placeholder="Nome, foglio, particella o gruppo"></label><div class="object-table-legend"><span>Seleziona più oggetti con i flag nella colonna Selez.</span><span>Il lucchetto impedisce modifiche e rimozioni.</span></div><div class="object-table-wrap"><table><thead><tr><th>Selez.</th><th>Blocca</th><th>Mappa</th><th>Nome</th><th>Gruppo</th><th>Foglio</th><th>Particella</th><th>Misure</th></tr></thead><tbody>${body || "<tr><td colspan='8'>Nessun oggetto</td></tr>"}</tbody></table></div>`);
const byId = new Map(records.map(record => [record.id, record]));
dialog.querySelector("input[type='search']").addEventListener("input", event => {
const query = event.target.value.toLocaleLowerCase("it");
dialog.querySelectorAll("tbody tr").forEach(row => { row.hidden = query && !row.textContent.toLocaleLowerCase("it").includes(query); });
});
dialog.querySelectorAll("[data-select]").forEach(input => input.addEventListener("change", () => {
if (input.checked) lmeState.managerSelectedObjectIds.add(input.dataset.select);
else lmeState.managerSelectedObjectIds.delete(input.dataset.select);
const record = byId.get(input.dataset.select);
if (record && input.checked) {
lmeState.managerHighlightedObjectId = record.id;
lmeState.managerHighlightedCategoryKey = record.categoryKey;
renderManagerHighlight([record]);
}
refreshLayerManager();
setInfo(`${lmeState.managerSelectedObjectIds.size} oggetti selezionati.`);
}));
dialog.querySelectorAll("[data-lock]").forEach(input => input.addEventListener("change", () => {
const record = byId.get(input.dataset.lock);
if (!record) return;
updateManagedProperties([record], properties => { properties._lmeLocked = input.checked; });
setInfo(input.checked ? "Oggetto bloccato." : "Oggetto sbloccato.");
}));
dialog.querySelectorAll("[data-locate]").forEach(button => button.addEventListener("click", () => {
const record = byId.get(button.dataset.locate); if (!record) return;
dialog.remove(); highlightManagedObject(record);
}));
return records.length;
}
function showAuditLog() {
const rows = [...lmeState.auditLog].reverse().map(item => `<tr><td>${escapeHtml(new Date(item.at).toLocaleString("it-IT"))}</td><td>${escapeHtml(item.action || "Modifica mappa")}</td></tr>`).join("");
showAppDialog("Registro operazioni", `<div class="object-table-wrap"><table><thead><tr><th>Data e ora</th><th>Operazione</th></tr></thead><tbody>${rows || "<tr><td colspan='2'>Nessuna operazione registrata</td></tr>"}</tbody></table></div>`);
return lmeState.auditLog.length;
}
function updateManagedProperties(records, change) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
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: `<span>${escapeHtml(managedObjectLabel(record, index))}</span>`, iconSize: null }) }).addTo(lmeState.permanentLabels);
}));
}
function layerLabelPosition(layer) {
if (typeof layer?.getBounds === "function" && layer.getBounds().isValid()) return layer.getBounds().getCenter();
if (typeof layer?.getLatLng === "function") return layer.getLatLng();
return null;
}
function fileToDataUrl(file) {
return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); });
}
function addDrawingFeature(feature, select = false) {
let added = null;
L.geoJSON(feature, {
pane: "drawingsPane",
style: storedFeatureStyle,
pointToLayer: (item, latlng) => item?.properties?._lmeShape === "circle" && Number(item.properties._lmeRadius) > 0
? L.circle(latlng, { pane: "drawingsPane", renderer: lmeState.drawingsRenderer, radius: Number(item.properties._lmeRadius) })
: L.marker(latlng, { pane: "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"}.`);
}
async function moveManagedRecords(records) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) { setInfo("Sblocca gli oggetti da spostare."); return; }
const result = await showFormDialog("Sposta oggetti", [
{ name: "north", label: "Metri verso nord (negativo verso sud)", type: "number", value: "0", step: "0.01" },
{ name: "east", label: "Metri verso est (negativo verso ovest)", type: "number", value: "0", step: "0.01" }
], "Sposta");
if (!result) return;
const north = Number(result.north);
if (!Number.isFinite(north)) return;
const east = Number(result.east);
if (!Number.isFinite(east) || (!north && !east)) return;
const distance = Math.hypot(north, east);
const bearing = Math.atan2(east, north) * 180 / Math.PI;
rememberMapChange();
records.forEach(record => {
const moved = turf.transformTranslate(record.layer.toGeoJSON(), distance, bearing, { units: "meters" });
replaceManagedLayer(record, moved);
});
finishManagedGeometryChange(`Spostati ${records.length} oggetti.`);
}
async function bufferManagedRecords(records) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
const result = await showFormDialog("Crea buffer", [{ name: "meters", label: "Distanza in metri", type: "number", value: "5", step: "0.01" }], "Crea buffer");
if (!result) return;
const meters = Number(result.meters);
if (!Number.isFinite(meters) || meters <= 0) return;
rememberMapChange();
let count = 0;
records.forEach((record, index) => {
const buffered = turf.buffer(record.layer.toGeoJSON(), meters, { units: "meters" });
if (!buffered) return;
buffered.properties = { ...(record.layer.feature?.properties || {}), name: `Buffer ${meters} m - ${managedObjectLabel(record, index)}`, _lmeShape: "polygon", _lmeManagerId: undefined };
addDrawingFeature(buffered); count++;
});
finishManagedGeometryChange(`Creati ${count} buffer da ${formatDistance(meters)}.`);
}
function unionManagedRecords(records) {
if (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 = new Map(layerManagerCategories()
.flatMap(category => managedRecordsForCategory(category))
.map(record => [record.id, record]));
lmeState.managerRecords = current;
return [...lmeState.managerSelectedObjectIds].map(id => current.get(id)).filter(Boolean);
}
function createHoleFromSelected() {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
const polygons = selectedManagedRecords().filter(record => record.layer instanceof L.Polygon);
if (polygons.length !== 2) { showGeometryResult("Creazione foro", "Seleziona esattamente due poligoni: l'area esterna e l'area interna da escludere.", "warning"); return false; }
try {
const ordered = [...polygons].sort((left, right) => turf.area(right.layer.toGeoJSON()) - turf.area(left.layer.toGeoJSON()));
const outer = ordered[0].layer.toGeoJSON();
const inner = ordered[1].layer.toGeoJSON();
if (!turf.booleanWithin(inner, outer)) throw new Error("il poligono interno deve essere completamente contenuto nell'area esterna");
const result = turf.difference(turf.featureCollection([outer, inner]));
if (!result) throw new Error("nessun risultato geometrico");
rememberMapChange();
result.properties = { ...(outer.properties || {}), name: `${managedObjectLabel(ordered[0], 0)} con area esclusa`, _lmeManagerId: undefined };
addDrawingFeature(result, true);
ordered.forEach(removeRecordWithoutPrompt);
lmeState.managerSelectedObjectIds.clear();
finishManagedGeometryChange("Poligono con foro creato correttamente.");
showGeometryResult("Foro creato", "Il nuovo poligono contiene l'area interna esclusa. I due oggetti originali sono stati sostituiti.", "success");
return true;
} catch (error) { showGeometryResult("Creazione foro non riuscita", error.message, "error"); return false; }
}
function validateAndRepairSelected() {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return "Progetto in sola lettura"; }
const records = selectedManagedRecords();
if (!records.length) { showGeometryResult("Controlla e ripara", "Seleziona uno o più oggetti dalla mappa, dal pannello Layer o dalla tabella oggetti.", "warning"); return "Nessuna selezione"; }
let valid = 0; let repaired = 0; let rejected = 0;
rememberMapChange();
records.forEach((record, index) => {
try {
let feature = turf.cleanCoords(record.layer.toGeoJSON(), { mutate: false });
if (feature.geometry?.type === "Polygon" && typeof turf.booleanValid === "function" && !turf.booleanValid(feature)) {
const parts = turf.unkinkPolygon(feature).features;
if (!parts.length) throw new Error("geometria non riparabile");
removeRecordWithoutPrompt(record);
parts.forEach((part, partIndex) => {
part.properties = { ...(feature.properties || {}), name: `${managedObjectLabel(record, index)} - riparato ${partIndex + 1}`, _lmeManagerId: undefined };
addDrawingFeature(part);
});
repaired++;
} else {
if (JSON.stringify(feature.geometry) !== JSON.stringify(record.layer.toGeoJSON().geometry)) {
replaceManagedLayer(record, feature); repaired++;
} else valid++;
}
} catch { rejected++; }
});
lmeState.managerSelectedObjectIds.clear();
const message = `Controllati ${records.length} oggetti: ${valid} validi, ${repaired} riparati, ${rejected} non riparabili.`;
finishManagedGeometryChange(message);
showGeometryResult("Controllo geometrie completato", message, rejected ? "warning" : "success");
return `${valid} valide, ${repaired} riparate, ${rejected} non riparabili`;
}
function compareSelectedGeometries() {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
const polygons = selectedManagedRecords().filter(record => record.layer instanceof L.Polygon);
if (polygons.length !== 2) { showGeometryResult("Confronta due confini", "Seleziona esattamente due poligoni dalla mappa, dal pannello Layer o dalla tabella oggetti.", "warning"); return false; }
try {
const first = polygons[0].layer.toGeoJSON(); const second = polygons[1].layer.toGeoJSON();
const onlyFirst = turf.difference(turf.featureCollection([first, second]));
const onlySecond = turf.difference(turf.featureCollection([second, first]));
const overlap = turf.intersect(turf.featureCollection([first, second]));
rememberMapChange();
const addResult = (feature, name, color) => {
if (!feature) return;
feature.properties = { name, _lmeColor: color, _lmeFillColor: color, _lmeFillOpacity: 0.38, _lmeManagerId: undefined };
addDrawingFeature(feature);
};
addResult(onlyFirst, `Solo ${managedObjectLabel(polygons[0], 0)}`, "#ef4444");
addResult(onlySecond, `Solo ${managedObjectLabel(polygons[1], 1)}`, "#3b82f6");
addResult(overlap, "Sovrapposizione", "#16a34a");
const firstArea = onlyFirst ? turf.area(onlyFirst) : 0; const secondArea = onlySecond ? turf.area(onlySecond) : 0; const overlapArea = overlap ? turf.area(overlap) : 0;
const message = `Confronto creato. Solo primo confine: ${formatArea(firstArea)}. Solo secondo confine: ${formatArea(secondArea)}. Sovrapposizione: ${formatArea(overlapArea)}.`;
finishManagedGeometryChange(message);
showGeometryResult("Confronto completato", `${message} Sulla mappa: rosso solo primo, blu solo secondo, verde sovrapposizione.`, "success");
return true;
} catch (error) { showGeometryResult("Confronto non riuscito", error.message, "error"); return false; }
}
function showGeometryResult(title, message, tone = "success") {
setInfo(`${title}: ${message}`);
const icons = { success: "✓", warning: "!", error: "×" };
showAppDialog(title, `<div class="geometry-result geometry-result-${tone}"><span aria-hidden="true">${icons[tone] || "i"}</span><p>${escapeHtml(message)}</p></div>`);
}
function addGeometryToolsControl() {
if (lmeState.geometryToolsControl || !lmeState.map) return;
const GeometryTools = L.Control.extend({
options: { position: "topright" },
onAdd() {
const container = L.DomUtil.create("div", "leaflet-bar lme-geometry-toolbar");
const actions = [
{ icon: "✓", title: "Controlla e ripara geometrie", run: validateAndRepairSelected },
{ icon: "◉", title: "Crea foro da due poligoni", run: createHoleFromSelected },
{ icon: "⇄", title: "Confronta due confini", run: compareSelectedGeometries }
];
actions.forEach(action => {
const button = L.DomUtil.create("a", "", container);
button.href = "#";
button.title = action.title;
button.setAttribute("role", "button");
button.setAttribute("aria-label", action.title);
button.textContent = action.icon;
L.DomEvent.on(button, "click", event => { L.DomEvent.stop(event); action.run(); });
});
L.DomEvent.disableClickPropagation(container);
return container;
}
});
lmeState.geometryToolsControl = new GeometryTools().addTo(lmeState.map);
}
async function numericEditManagedRecord(record) {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
if (record.layer?.feature?.properties?._lmeLocked) { setInfo("Sblocca prima l'oggetto."); return; }
if (record.layer instanceof L.Circle) {
const center = record.layer.getLatLng();
const result = await showFormDialog("Modifica cerchio", [{ name: "coordinates", label: "Latitudine, longitudine, raggio in metri", type: "textarea", rows: 3, value: `${center.lat.toFixed(7)}, ${center.lng.toFixed(7)}, ${record.layer.getRadius().toFixed(2)}` }], "Applica");
if (!result) return;
const value = result.coordinates;
const parts = value.split(/[;, ]+/).map(Number).filter(Number.isFinite);
if (parts.length < 3) { setInfo("Inserisci latitudine, longitudine e raggio validi."); return; }
rememberMapChange(); record.layer.setLatLng([parts[0], parts[1]]); record.layer.setRadius(parts[2]); annotateDrawingLayer(record.layer, "circle");
} else if (typeof record.layer?.getLatLng === "function") {
const point = record.layer.getLatLng();
const result = await showFormDialog("Modifica punto", [{ name: "coordinates", label: "Latitudine, longitudine", type: "textarea", rows: 3, value: `${point.lat.toFixed(7)}, ${point.lng.toFixed(7)}` }], "Applica");
if (!result) return;
const value = result.coordinates;
const parts = value.split(/[;, ]+/).map(Number).filter(Number.isFinite);
if (parts.length < 2) { setInfo("Inserisci coordinate valide."); return; }
rememberMapChange(); record.layer.setLatLng([parts[0], parts[1]]);
} else {
const lines = lineLatLngGroups(record.layer.getLatLngs());
const current = lines[0]?.map(point => `${point.lat.toFixed(7)},${point.lng.toFixed(7)}`).join("; ") || "";
const result = await showFormDialog("Modifica vertici", [{ name: "coordinates", label: "Coppie latitudine,longitudine separate da punto e virgola", type: "textarea", rows: 8, value: current }], "Applica");
if (!result) return;
const value = result.coordinates;
const points = value.split(";").map(pair => pair.split(",").map(Number)).filter(pair => pair.length === 2 && pair.every(Number.isFinite));
if (points.length < 2) { setInfo("Servono almeno due vertici validi."); return; }
rememberMapChange(); record.layer.setLatLngs(points.map(point => L.latLng(point[0], point[1])));
}
finishManagedGeometryChange("Coordinate aggiornate.");
}
function replaceManagedLayer(record, feature) {
const parent = record.parent?.hasLayer?.(record.layer) ? record.parent : lmeState.drawings;
parent.removeLayer?.(record.layer);
clearManagedObjectState(record.id);
feature.properties = { ...(record.layer.feature?.properties || {}), ...(feature.properties || {}), _lmeManagerId: undefined };
addDrawingFeature(feature);
}
function removeRecordWithoutPrompt(record) {
record.parent?.removeLayer?.(record.layer); clearManagedObjectState(record.id);
}
function finishManagedGeometryChange(message) {
refreshImportedMeasurements(); refreshDrawingMeasurements(); refreshPermanentLabels(); updateGpsInsideState(); refreshLayerManager(); finishMapChange(); setInfo(message);
}
function rememberMapChange() {
if (lmeState.historyBusy || !lmeState.map) return;
const snapshot = createProjectSnapshot(true);
if (lmeState.undoStack.at(-1) !== snapshot) lmeState.undoStack.push(snapshot);
if (lmeState.undoStack.length > 30) lmeState.undoStack.shift();
lmeState.redoStack = [];
}
function finishMapChange() {
if (lmeState.historyBusy || !lmeState.map) return;
const action = arguments[0] || "Modifica mappa";
lmeState.isDirty = true;
lmeState.auditLog.push({ at: new Date().toISOString(), action });
if (lmeState.auditLog.length > 250) lmeState.auditLog.shift();
if (lmeState.autoSaveEnabled) {
try { localStorage.setItem("lmeAutosaveV3", createProjectSnapshot(true)); } catch { }
}
window.clearTimeout(lmeState.changeNotifyTimer);
lmeState.changeNotifyTimer = window.setTimeout(() => {
void lmeState.dotNetReference?.invokeMethodAsync("OnMapChanged", action);
}, 450);
}
function getHistoryState() {
return { canUndo: lmeState.undoStack.length > 0, canRedo: lmeState.redoStack.length > 0, dirty: lmeState.isDirty };
}
function markProjectSaved() {
lmeState.isDirty = false;
void lmeState.dotNetReference?.invokeMethodAsync("OnProjectSaved");
}
function confirmDiscardChanges(actionLabel) {
return !lmeState.isDirty || window.confirm(`Ci sono modifiche non salvate. Continuare e ${actionLabel || "abbandonarle"}?`);
}
function setProjectReadOnly(enabled) {
lmeState.readOnly = Boolean(enabled);
document.querySelector(".map-app")?.classList.toggle("project-readonly", lmeState.readOnly);
if (lmeState.readOnly) setDrawToolsVisible(false);
refreshLayerManager();
setInfo(lmeState.readOnly ? "Progetto aperto in sola lettura. Duplicalo per modificarlo." : "Progetto modificabile.");
}
function undoMapChange() {
if (!lmeState.undoStack.length) { setInfo("Nessuna operazione da annullare."); return; }
lmeState.redoStack.push(createProjectSnapshot(true));
restoreHistorySnapshot(lmeState.undoStack.pop(), "Operazione annullata.");
lmeState.isDirty = true;
}
function redoMapChange() {
if (!lmeState.redoStack.length) { setInfo("Nessuna operazione da ripristinare."); return; }
lmeState.undoStack.push(createProjectSnapshot(true));
restoreHistorySnapshot(lmeState.redoStack.pop(), "Operazione ripristinata.");
lmeState.isDirty = true;
}
function restoreLastTrashItem() {
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
const entry = lmeState.trash.pop();
if (!entry) { setInfo("Il cestino e' vuoto."); return false; }
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.autoFitSelectedObject = Boolean(value.autoFitSelectedObject);
lmeState.gpsProximityEnabled = value.gpsProximityEnabled !== false;
lmeState.gpsBoundaryAlertEnabled = Boolean(value.gpsBoundaryAlertEnabled);
}
function compareProjectSnapshots(currentRaw, previousRaw) {
const current = typeof currentRaw === "string" ? JSON.parse(currentRaw) : currentRaw;
const previous = typeof previousRaw === "string" ? JSON.parse(previousRaw) : previousRaw;
const collect = snapshot => ["drawings", "imported", "importedSheets", "selectedParcels"]
.flatMap(key => snapshot?.[key]?.features || []);
const keyFor = (feature, index) => feature?.properties?._lmeManagerId
|| `${feature?.properties?.name || feature?.properties?.Name || "object"}:${index}`;
const currentMap = new Map(collect(current).map((feature, index) => [keyFor(feature, index), JSON.stringify(feature)]));
const previousMap = new Map(collect(previous).map((feature, index) => [keyFor(feature, index), JSON.stringify(feature)]));
let added = 0; let removed = 0; let changed = 0;
currentMap.forEach((value, key) => { if (!previousMap.has(key)) added++; else if (previousMap.get(key) !== value) changed++; });
previousMap.forEach((_value, key) => { if (!currentMap.has(key)) removed++; });
const message = `Confronto revisione: ${added} aggiunti, ${removed} rimossi, ${changed} modificati.`;
setInfo(message);
return message;
}
function renderLayerManagerVisibilityOption(label, checked, onChange) {
const option = document.createElement("label");
option.className = "layer-manager-suboption";
option.innerHTML = `<input type="checkbox" ${checked ? "checked" : ""}><span>${escapeHtml(label)}</span>`;
const checkbox = option.querySelector("input");
checkbox.addEventListener("click", event => event.stopPropagation());
checkbox.addEventListener("change", () => onChange(checkbox.checked));
return option;
}
function geometryVisibilityLabel(layer) {
if (layer instanceof L.Polygon || layer instanceof L.Circle) {
return "Area/geometria sulla mappa";
}
if (layer instanceof L.Polyline) {
return "Linea sulla mappa";
}
return "Punto sulla mappa";
}
function supportsManagedMeasurements(record) {
return ["imported", "drawings"].includes(record.categoryKey)
&& (record.layer instanceof L.Circle
|| record.layer instanceof L.Polygon
|| record.layer instanceof L.Polyline);
}
function managedMeasurementsGloballyVisible(categoryKey) {
return categoryKey === "imported"
? lmeState.showImportedMeasurements
: lmeState.showDrawingMeasurements;
}
function managedMeasurementKinds(record) {
if (record.layer instanceof L.Polygon) {
return [
{ key: "summary", label: "Superficie e perimetro sulla mappa" },
{ key: "sides", label: "Misure dei lati sulla mappa" }
];
}
if (record.layer instanceof L.Circle) {
return [{ key: "summary", label: "Superficie, raggio e circonferenza sulla mappa" }];
}
return [
{ key: "summary", label: "Lunghezza totale sulla mappa" },
{ key: "sides", label: "Misure dei tratti sulla mappa" }
];
}
function managedMeasurementHiddenSet(kind) {
return kind === "sides"
? lmeState.managerHiddenMeasurementSideIds
: lmeState.managerHiddenMeasurementSummaryIds;
}
function setManagedMeasurementKindVisible(record, kind, visible) {
const hiddenSet = managedMeasurementHiddenSet(kind);
if (visible) {
if (!managedMeasurementsGloballyVisible(record.categoryKey)) {
lmeState.managerRecords.forEach(candidate => {
if (candidate.categoryKey === record.categoryKey
&& supportsManagedMeasurements(candidate)) {
lmeState.managerHiddenMeasurementSummaryIds.add(candidate.id);
lmeState.managerHiddenMeasurementSideIds.add(candidate.id);
}
});
if (record.categoryKey === "imported") {
lmeState.showImportedMeasurements = true;
} else {
lmeState.showDrawingMeasurements = true;
}
void lmeState.dotNetReference?.invokeMethodAsync(
"SetMeasurementsFromLayerManager",
record.categoryKey,
true);
}
hiddenSet.delete(record.id);
} else {
hiddenSet.add(record.id);
}
refreshImportedMeasurements();
refreshDrawingMeasurements();
refreshLayerManager();
}
function captureLayerManagerExpansion(container) {
container.querySelectorAll("details[data-expansion-key]").forEach(row => {
if (row.open) {
lmeState.managerExpandedRows.add(row.dataset.expansionKey);
} else {
lmeState.managerExpandedRows.delete(row.dataset.expansionKey);
}
});
}
function bindLayerManagerExpansion(row, key) {
row.dataset.expansionKey = key;
row.open = lmeState.managerExpandedRows.has(key);
row.addEventListener("toggle", () => {
if (row.open) {
lmeState.managerExpandedRows.add(key);
} else {
lmeState.managerExpandedRows.delete(key);
}
const button = row.querySelector(":scope > summary > .layer-expand-button");
if (button) {
button.textContent = row.open ? "▾" : "▸";
button.setAttribute("aria-expanded", String(row.open));
}
});
}
function layerExpandButton(expanded, label) {
return `<button type="button" class="layer-expand-button" aria-label="${expanded ? "Comprimi" : "Espandi"} ${escapeHtml(label)}" aria-expanded="${expanded}">${expanded ? "▾" : "▸"}</button>`;
}
function bindLayerManagerToggleButton(row) {
const summary = row.querySelector(":scope > summary");
const button = summary?.querySelector(".layer-expand-button");
summary?.addEventListener("click", event => event.preventDefault());
button?.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
row.open = !row.open;
});
}
function setManagedCategoryVisible(category, visible) {
if (category.key === "imported") {
lmeState.importedVisible = visible;
} else if (category.key === "importedSheets") {
lmeState.importedSheetsVisible = visible;
} else if (category.key === "selectedParcels") {
lmeState.selectedParcelsVisible = visible;
} else if (category.key === "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]);
if (lmeState.autoFitSelectedObject) {
fitLayer(record.layer);
}
revealManagedRecordInLayerManager(record.id);
}
function revealManagedRecordInLayerManager(recordId) {
const row = document.querySelector(`.layer-manager-object[data-object-id="${CSS.escape(recordId)}"]`);
if (!row) return;
const block = row.closest(".layer-manager-block");
const group = row.closest(".layer-manager-group");
if (group) group.classList.add("layer-manager-selection-path");
if (block) block.classList.add("layer-manager-selection-path");
row.classList.add("layer-manager-object-selected");
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
function drawingEditToolbar() {
return lmeState.drawControl?._toolbars?.[L.EditToolbar.TYPE] || null;
}
function drawingEditActive() {
return Boolean(drawingEditToolbar()?.enabled?.());
}
function updateDrawingSelectionActions() {
const hasSelection = Boolean(lmeState.drawingSelection?.getLayers?.().length);
const hasDrawings = Boolean(lmeState.drawings?.getLayers?.().length);
lmeState.drawingDeleteButton?.classList.toggle("leaflet-disabled", !hasDrawings);
if (lmeState.drawingDeleteButton) {
lmeState.drawingDeleteButton.setAttribute("aria-disabled", String(!hasDrawings));
}
lmeState.drawingScopeMenu?.querySelector("[data-drawing-scope='selected']")
?.setAttribute("aria-disabled", String(!hasSelection));
}
function setSelectedDrawing(layer) {
if (!lmeState.drawingSelection || drawingEditActive()) {
return;
}
lmeState.drawingSelection.clearLayers();
if (layer) {
lmeState.drawingSelection.addLayer(layer);
}
updateDrawingSelectionActions();
}
function selectDrawingLayer(layer) {
if (drawingEditActive()) {
return;
}
const record = {
id: managedLayerId(layer, "drawings"),
categoryKey: "drawings",
layer,
parent: lmeState.drawings,
source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente")
};
clearManagerHighlight();
lmeState.managerHighlightedObjectId = record.id;
lmeState.managerHighlightedCategoryKey = "drawings";
document.querySelectorAll(".layer-manager-object").forEach(row => {
row.classList.toggle("layer-manager-object-selected", row.dataset.objectId === record.id);
});
setSelectedDrawing(layer);
renderManagerHighlight([record]);
setInfo(`Selezione: ${managedObjectLabel(record, 0)}. Usa la matita per modificare o il cestino per eliminare.`);
}
function bindManagedParentHighlight(row, key, records) {
const summary = row.querySelector(":scope > summary");
row.dataset.highlightKey = key;
row.classList.toggle("layer-manager-parent-selected", lmeState.managerHighlightedParentKey === key);
if (lmeState.managerHighlightedParentKey === key) {
lmeState.managerHighlightedParentRecords = records;
}
row.querySelector(":scope > summary > .layer-manager-title").addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
highlightManagedParent(row, key, records);
});
summary.addEventListener("pointerenter", () => {
lmeState.managerHoveredParentKey = key;
lmeState.managerHoveredParentRecords = records;
row.classList.add("layer-manager-parent-highlighted");
renderManagerHighlight(records);
});
summary.addEventListener("pointerleave", () => {
if (lmeState.managerHoveredParentKey !== key) {
return;
}
lmeState.managerHoveredParentKey = null;
lmeState.managerHoveredParentRecords = null;
row.classList.remove("layer-manager-parent-highlighted");
renderSelectedManagerHighlight();
});
}
function highlightManagedParent(row, key, records) {
if (lmeState.managerHighlightedParentKey === key) {
clearManagerHighlight();
return;
}
clearManagerHighlight();
lmeState.managerHighlightedParentKey = key;
lmeState.managerHighlightedParentRecords = records;
lmeState.managerHighlightedCategoryKey = records[0]?.categoryKey || null;
setSelectedDrawing(null);
row.classList.add("layer-manager-parent-selected");
renderManagerHighlight(records);
}
function renderSelectedManagerHighlight() {
if (lmeState.managerHighlightedParentRecords?.length) {
renderManagerHighlight(lmeState.managerHighlightedParentRecords);
return;
}
const selected = lmeState.managerRecords.get(lmeState.managerHighlightedObjectId);
renderManagerHighlight(selected ? [selected] : []);
}
function renderManagerHighlight(records) {
lmeState.managerHighlight?.clearLayers();
const features = records.flatMap(record => {
const geojson = record.layer?.toGeoJSON?.();
if (!geojson) {
return [];
}
return geojson.type === "FeatureCollection" ? geojson.features : [geojson];
});
if (!features.length) {
return;
}
L.geoJSON({ type: "FeatureCollection", features }, {
pane: "managerHighlightPane",
interactive: false,
style: { color: "#ff00d4", weight: 8, opacity: 1, fillColor: "#00fff0", fillOpacity: 0.16 },
pointToLayer: (feature, latlng) => L.circleMarker(latlng, {
pane: "managerHighlightPane",
radius: 12,
color: "#ff00d4",
weight: 5,
fillColor: "#00fff0",
fillOpacity: 0.9,
interactive: false
})
}).addTo(lmeState.managerHighlight);
}
function clearManagerHighlight() {
lmeState.managerHighlight?.clearLayers();
lmeState.managerHighlightedObjectId = null;
lmeState.managerHighlightedCategoryKey = null;
lmeState.managerHighlightedParentKey = null;
lmeState.managerHighlightedParentRecords = null;
lmeState.managerHoveredParentKey = null;
lmeState.managerHoveredParentRecords = null;
setSelectedDrawing(null);
document.querySelectorAll(".layer-manager-object-selected").forEach(row => {
row.classList.remove("layer-manager-object-selected");
});
document.querySelectorAll(".layer-manager-parent-highlighted").forEach(row => {
row.classList.remove("layer-manager-parent-highlighted");
});
document.querySelectorAll(".layer-manager-parent-selected").forEach(row => {
row.classList.remove("layer-manager-parent-selected");
});
}
function layerManagerMoveButtons(label) {
return `<span class="layer-move-buttons"><button type="button" class="layer-move-up" aria-label="Sposta ${label} su" title="Sposta su">↑</button><button type="button" class="layer-move-down" aria-label="Sposta ${label} giù" title="Sposta giù">↓</button></span>`;
}
function bindLayerManagerDrag(container) {
container.querySelectorAll(".layer-drag-handle").forEach(handle => {
handle.addEventListener("pointerdown", event => {
event.preventDefault();
event.stopPropagation();
const row = handle.closest("[data-drag-type]");
const list = row.parentElement;
row.classList.add("layer-manager-dragging");
handle.setPointerCapture?.(event.pointerId);
const move = moveEvent => {
moveEvent.preventDefault();
const target = document.elementFromPoint(moveEvent.clientX, moveEvent.clientY)?.closest(`[data-drag-type="${row.dataset.dragType}"]`);
if (!target || target === row || target.parentElement !== list) {
return;
}
const targetRect = target.getBoundingClientRect();
list.insertBefore(row, moveEvent.clientY < targetRect.top + targetRect.height / 2 ? target : target.nextSibling);
};
const end = () => {
row.classList.remove("layer-manager-dragging");
handle.releasePointerCapture?.(event.pointerId);
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", end);
document.removeEventListener("pointercancel", end);
applyLayerManagerDomOrder(container);
};
document.addEventListener("pointermove", move, { passive: false });
document.addEventListener("pointerup", end);
document.addEventListener("pointercancel", end);
});
});
}
function bindLayerManagerMoveButtons(container) {
container.querySelectorAll(".layer-move-up, .layer-move-down").forEach(button => {
button.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
const row = button.closest("[data-drag-type]");
const sibling = button.classList.contains("layer-move-up")
? row.previousElementSibling
: row.nextElementSibling;
if (!sibling || sibling.dataset.dragType !== row.dataset.dragType) {
return;
}
if (button.classList.contains("layer-move-up")) {
row.parentElement.insertBefore(row, sibling);
} else {
row.parentElement.insertBefore(sibling, row);
}
applyLayerManagerDomOrder(container);
});
});
}
function applyLayerManagerDomOrder(container) {
const categoryKeys = [...container.children].map(row => row.dataset.categoryKey).filter(Boolean);
setLayerOrder(categoryKeys);
container.querySelectorAll(":scope > .layer-manager-group").forEach(group => {
const objectIds = [...group.querySelectorAll(".layer-manager-object")].map(row => row.dataset.objectId).filter(Boolean);
lmeState.managerObjectOrder[group.dataset.categoryKey] = objectIds;
});
applyManagedVisualOrder(container);
saveLayerManagerState();
}
function applyManagedVisualOrder(container) {
container.querySelectorAll(":scope > .layer-manager-group").forEach(group => {
const objectIds = [...group.querySelectorAll(".layer-manager-object")].map(row => row.dataset.objectId).filter(Boolean);
[...objectIds].reverse().forEach(id => lmeState.managerRecords.get(id)?.layer?.bringToFront?.());
objectIds.forEach((id, index) => lmeState.managerRecords.get(id)?.layer?.setZIndexOffset?.(1000 - index));
});
}
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(`<strong>${escapeHtml(label)}</strong><br>${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 = `<li><a href="#" data-drawing-scope="selected">Solo selezionato</a></li><li><a href="#" data-drawing-scope="all">Tutti</a></li>`;
button.closest(".leaflet-draw-section")?.appendChild(menu);
lmeState.drawingScopeMenu = menu;
const hasSelection = Boolean(lmeState.drawingSelection?.getLayers?.().length);
const selectedAction = menu.querySelector("[data-drawing-scope='selected']");
selectedAction.setAttribute("aria-disabled", String(!hasSelection));
menu.querySelectorAll("a").forEach(link => {
L.DomEvent.on(link, "click", L.DomEvent.stop)
.on(link, "mousedown", L.DomEvent.stopPropagation)
.on(link, "touchstart", L.DomEvent.stopPropagation)
.on(link, "click", () => runDrawingScopeAction(action, link.dataset.drawingScope));
});
}
function hideDrawingScopeMenu() {
lmeState.drawingScopeMenu?.remove();
lmeState.drawingScopeMenu = null;
}
function runDrawingScopeAction(action, scope) {
const targetGroup = scope === "selected" ? lmeState.drawingSelection : lmeState.drawings;
if (!targetGroup?.getLayers?.().length) {
setInfo("Seleziona prima un oggetto sulla mappa.");
return;
}
hideDrawingScopeMenu();
if (action === "edit") {
startScopedDrawingEdit(targetGroup, scope);
} else if (scope === "selected") {
removeSelectedDrawing();
} else {
clearDrawings();
}
}
function startScopedDrawingEdit(targetGroup, scope) {
const toolbar = drawingEditToolbar();
const mode = toolbar?._modes?.[L.EditToolbar.Edit.TYPE];
if (!mode?.handler) {
return;
}
const locked = targetGroup.getLayers().filter(layer => layer?.feature?.properties?._lmeLocked);
if (locked.length) { setInfo("Sblocca gli oggetti prima di modificarli."); return; }
rememberMapChange();
mode.handler._featureGroup = targetGroup;
mode.handler.options.featureGroup = targetGroup;
mode.handler.enable();
setInfo(scope === "selected"
? "Modifica attiva sul solo oggetto selezionato."
: "Modifica attiva su tutti i disegni e POI.");
}
function removeSelectedDrawing() {
const layer = lmeState.drawingSelection?.getLayers?.()[0];
if (!layer) {
setInfo("Seleziona prima il disegno da eliminare toccandolo sulla mappa.");
return;
}
if (drawingEditActive()) {
setInfo("Salva o annulla la modifica prima di eliminare il disegno.");
return;
}
if (layer.feature?.properties?._lmeLocked) { setInfo("Sblocca prima l'oggetto selezionato."); return; }
const record = {
id: managedLayerId(layer, "drawings"),
categoryKey: "drawings",
layer,
parent: lmeState.drawings
};
const name = managedObjectLabel(record, 0);
if (!window.confirm(`Eliminare solo '${name}' dal progetto?`)) {
return;
}
rememberMapChange();
lmeState.drawingSelection.clearLayers();
lmeState.drawings.removeLayer(layer);
clearManagedObjectState(record.id);
clearManagerHighlight();
refreshDrawingMeasurements();
updateGpsInsideState();
refreshLayerManager();
finishMapChange();
setInfo(`'${name}' eliminato dal progetto.`);
}
function startMeasurementDrawing(mode) {
if (!lmeState.map) {
return;
}
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();
window.requestAnimationFrame(() => revealManagedRecordInLayerManager(id));
});
}
function bindManagedMapSelection(layer, categoryKey, parent) {
if (layer.lmeManagedMapSelectionBound) return;
layer.lmeManagedMapSelectionBound = true;
layer.on("click", event => {
if (Date.now() < lmeState.ignoreMapClickUntil) return;
lmeState.ignoreMapClickUntil = Date.now() + 600;
if (event.originalEvent) {
event.originalEvent._lmeDrawingSelected = true;
L.DomEvent.stopPropagation(event.originalEvent);
}
const record = { id: managedLayerId(layer, categoryKey), categoryKey, layer, parent, source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente") };
const multiple = event.originalEvent?.ctrlKey || event.originalEvent?.metaKey || event.originalEvent?.shiftKey;
if (!multiple) lmeState.managerSelectedObjectIds.clear();
if (multiple && lmeState.managerSelectedObjectIds.has(record.id)) lmeState.managerSelectedObjectIds.delete(record.id);
else lmeState.managerSelectedObjectIds.add(record.id);
highlightManagedObject(record);
refreshLayerManager();
window.requestAnimationFrame(() => revealManagedRecordInLayerManager(record.id));
setInfo(`Selezionato dalla mappa: ${managedObjectEditableName(record) || "oggetto"}.`);
});
}
function setDrawToolsVisible(visible) {
if (visible) {
enableDrawTools();
return;
}
if (!lmeState.drawControl) {
return;
}
Object.values(lmeState.drawControl._toolbars || {}).forEach(toolbar => {
if (typeof toolbar.disable === "function") {
toolbar.disable();
}
});
lmeState.map.removeControl(lmeState.drawControl);
lmeState.drawControl = null;
lmeState.drawingDeleteButton = null;
lmeState.drawingScopeMenu = null;
setInfo("Strumenti di disegno nascosti.");
}
function clearDrawings() {
const count = managedGroupGeoJson(lmeState.drawings, "drawings").features.length;
if (!count) {
setInfo("Non ci sono disegni o POI da eliminare.");
return;
}
if (!window.confirm(`Eliminare ${count} disegni e POI dalla mappa?`)) {
return;
}
if (managedRecordsForCategory(layerManagerCategories().find(item => item.key === "drawings")).some(record => record.layer?.feature?.properties?._lmeLocked)) {
setInfo("Sblocca tutti i disegni prima di eliminarli."); return;
}
rememberMapChange();
drawingEditToolbar()?.disable?.();
lmeState.drawingSelection?.clearLayers();
clearManagerHighlight();
lmeState.drawings.clearLayers();
clearManagedCategoryState("drawings");
lmeState.drawingMeasurements.clearLayers();
updateGpsInsideState();
setInfo("Disegni e POI eliminati dalla mappa.");
refreshLayerManager();
finishMapChange();
}
function snapDrawingLayer(layer) {
if (!lmeState.snapEnabled || !layer || layer instanceof L.Circle || layer instanceof L.Marker) return;
const candidates = [];
const segments = [];
layerManagerCategories().forEach(category => managedRecordsForCategory(category).forEach(record => {
if (record.layer === layer) return;
lineLatLngGroups(record.layer?.getLatLngs?.() || []).forEach(line => {
line.forEach(point => candidates.push(point));
for (let index = 1; index < line.length; index++) segments.push([line[index - 1], line[index]]);
if (record.layer instanceof L.Polygon && line.length > 2) segments.push([line.at(-1), line[0]]);
});
const point = record.layer?.getLatLng?.(); if (point) candidates.push(point);
}));
const snapPoint = point => {
const pixel = lmeState.map.latLngToContainerPoint(point);
let nearest = null; let distance = lmeState.snapDistancePixels;
candidates.forEach(candidate => { const current = pixel.distanceTo(lmeState.map.latLngToContainerPoint(candidate)); if (current < distance) { distance = current; nearest = candidate; } });
segments.forEach(([start, end]) => {
const a = lmeState.map.latLngToContainerPoint(start); const b = lmeState.map.latLngToContainerPoint(end);
const lengthSquared = (b.x - a.x) ** 2 + (b.y - a.y) ** 2;
if (!lengthSquared) return;
const ratio = Math.max(0, Math.min(1, ((pixel.x - a.x) * (b.x - a.x) + (pixel.y - a.y) * (b.y - a.y)) / lengthSquared));
const projected = L.point(a.x + ratio * (b.x - a.x), a.y + ratio * (b.y - a.y));
const current = pixel.distanceTo(projected);
if (current < distance) { distance = current; nearest = lmeState.map.containerPointToLatLng(projected); }
const midpoint = L.point((a.x + b.x) / 2, (a.y + b.y) / 2);
const midpointDistance = pixel.distanceTo(midpoint);
if (midpointDistance < distance) { distance = midpointDistance; nearest = lmeState.map.containerPointToLatLng(midpoint); }
});
return nearest ? L.latLng(nearest.lat, nearest.lng) : point;
};
const transform = value => Array.isArray(value) ? value.map(transform) : snapPoint(value);
layer.setLatLngs(transform(layer.getLatLngs()));
}
function scheduleMeasurementRefresh() {
window.clearTimeout(lmeState.measurementRefreshTimer);
lmeState.measurementRefreshTimer = window.setTimeout(() => {
refreshImportedMeasurements();
refreshDrawingMeasurements();
}, 80);
}
function refreshImportedMeasurements() {
renderMeasurements(
lmeState.imported,
lmeState.importedMeasurements,
lmeState.showImportedMeasurements,
"imported");
}
function refreshDrawingMeasurements() {
renderMeasurements(
lmeState.drawings,
lmeState.drawingMeasurements,
lmeState.showDrawingMeasurements,
"drawing");
}
function renderMeasurements(sourceGroup, targetGroup, visible, sourceType) {
if (!sourceGroup || !targetGroup) {
return;
}
targetGroup.clearLayers();
if (!visible || !lmeState.map.hasLayer(sourceGroup)) {
return;
}
const context = {
count: 0,
max: 600,
showSides: lmeState.map.getZoom() >= 17,
bounds: lmeState.map.getBounds(),
sourceType
};
visitMeasurableLayers(sourceGroup, layer => {
const categoryKey = sourceType === "imported" ? "imported" : "drawings";
const objectId = managedLayerId(layer, categoryKey);
const showSummary = !lmeState.managerHiddenMeasurementSummaryIds.has(objectId);
const showSides = !lmeState.managerHiddenMeasurementSideIds.has(objectId);
if (!showSummary && !showSides) {
return;
}
if (context.count >= context.max || !layerIsInView(layer, context.bounds)) {
return;
}
const layerContext = {
...context,
showSummary,
showSides: context.showSides && showSides
};
if (layer instanceof L.Circle) {
renderCircleMeasurement(layer, targetGroup, layerContext);
} else if (layer instanceof L.Polygon) {
renderPolygonMeasurement(layer, targetGroup, layerContext);
} else if (layer instanceof L.Polyline) {
renderPolylineMeasurement(layer, targetGroup, layerContext);
}
});
}
function visitMeasurableLayers(layer, visitor) {
if (layer instanceof L.Circle || layer instanceof L.Polygon || layer instanceof L.Polyline) {
visitor(layer);
return;
}
if (typeof layer.eachLayer === "function") {
layer.eachLayer(child => visitMeasurableLayers(child, visitor));
}
}
function layerIsInView(layer, mapBounds) {
if (typeof layer.getBounds === "function") {
const bounds = layer.getBounds();
return !bounds.isValid || !bounds.isValid() || bounds.intersects(mapBounds);
}
if (typeof layer.getLatLng === "function") {
return mapBounds.contains(layer.getLatLng());
}
return true;
}
function renderCircleMeasurement(layer, targetGroup, context) {
if (!context.showSummary) {
return;
}
const radius = layer.getRadius();
const area = Math.PI * radius * radius;
const circumference = 2 * Math.PI * radius;
addMeasurementLabel(
targetGroup,
layer.getLatLng(),
`S: ${formatArea(area)}<br>R: ${formatDistance(radius)} · C: ${formatDistance(circumference)}`,
"summary",
context);
}
function renderPolygonMeasurement(layer, targetGroup, context) {
const polygons = polygonLatLngGroups(layer.getLatLngs());
let area = 0;
let perimeter = 0;
polygons.forEach(rings => {
rings.forEach((ring, index) => {
const ringArea = Math.abs(L.GeometryUtil.geodesicArea(ring));
area += index === 0 ? ringArea : -ringArea;
perimeter += latLngPathLength(ring, true);
if (context.showSides) {
addSegmentLabels(ring, true, targetGroup, context);
}
});
});
const center = typeof layer.getCenter === "function"
? layer.getCenter()
: layer.getBounds().getCenter();
if (context.showSummary) {
addMeasurementLabel(
targetGroup,
center,
`S: ${formatArea(Math.max(0, area))}<br>P: ${formatDistance(perimeter)}`,
"summary",
context);
}
}
function renderPolylineMeasurement(layer, targetGroup, context) {
const lines = lineLatLngGroups(layer.getLatLngs());
const length = lines.reduce((total, line) => total + latLngPathLength(line, false), 0);
if (context.showSides) {
lines.forEach(line => addSegmentLabels(line, false, targetGroup, context));
}
const longest = lines.reduce((current, line) => line.length > current.length ? line : current, []);
if (context.showSummary && longest.length) {
addMeasurementLabel(
targetGroup,
longest[Math.floor(longest.length / 2)],
`L: ${formatDistance(length)}`,
"summary",
context);
}
}
function polygonLatLngGroups(latLngs) {
if (!Array.isArray(latLngs) || !latLngs.length) {
return [];
}
if (isLatLng(latLngs[0])) {
return [[latLngs]];
}
if (Array.isArray(latLngs[0]) && isLatLng(latLngs[0][0])) {
return [latLngs];
}
return latLngs.flatMap(polygonLatLngGroups);
}
function lineLatLngGroups(latLngs) {
if (!Array.isArray(latLngs) || !latLngs.length) {
return [];
}
if (isLatLng(latLngs[0])) {
return [latLngs];
}
return latLngs.flatMap(lineLatLngGroups);
}
function isLatLng(value) {
return value && Number.isFinite(value.lat) && Number.isFinite(value.lng);
}
function latLngPathLength(points, closed) {
if (points.length < 2) {
return 0;
}
let total = 0;
for (let index = 1; index < points.length; index += 1) {
total += lmeState.map.distance(points[index - 1], points[index]);
}
if (closed && !points[0].equals(points[points.length - 1])) {
total += lmeState.map.distance(points[points.length - 1], points[0]);
}
return total;
}
function addSegmentLabels(points, closed, targetGroup, context) {
if (points.length < 2) {
return;
}
const segmentCount = closed ? points.length : points.length - 1;
for (let index = 0; index < segmentCount && context.count < context.max; index += 1) {
const start = points[index];
const end = points[(index + 1) % points.length];
if (start.equals(end)) {
continue;
}
addMeasurementLabel(
targetGroup,
L.latLng((start.lat + end.lat) / 2, (start.lng + end.lng) / 2),
formatDistance(lmeState.map.distance(start, end)),
"side",
context);
}
}
function addMeasurementLabel(targetGroup, latLng, html, labelType, context) {
if (context.count >= context.max) {
return;
}
L.marker(latLng, {
pane: context.sourceType === "drawing" ? "drawingsPane" : "importedPane",
interactive: false,
keyboard: false,
icon: L.divIcon({
className: `lme-measure-label ${context.sourceType} ${labelType}`,
html: `<span>${html}</span>`,
iconSize: null
})
}).addTo(targetGroup).lmeManagerLabel = html.replace(/<[^>]+>/g, "");
context.count += 1;
}
function formatDistance(meters) {
if (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(`<strong>${escapeHtml(reference)}</strong>`);
}
}).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 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/") ? `<img class="lme-popup-photo" src="${photo}" alt="Foto allegata">` : "";
layer.bindPopup(`${label ? `<strong>${escapeHtml(label)}</strong>` : ""}${notes ? `<p>${escapeHtml(notes)}</p>` : ""}${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("<body") ? text : escapeHtml(text);
} catch {
return "";
}
}
function fitLayer(layer) {
try {
const bounds = layer.getBounds();
if (bounds.isValid()) {
lmeState.map.fitBounds(bounds.pad(0.15));
}
} catch {
setInfo("Nessuna geometria da inquadrare.");
}
}
function cadastreUrl() {
return "https://wms.cartografia.agenziaentrate.gov.it/inspire/wms/ows01.php?language=ita&";
}
function setInfo(text) {
setText("mapInfo", text);
}
function setText(id, text) {
const element = document.getElementById(id);
if (element) {
element.textContent = text;
}
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}