4508 lines
180 KiB
JavaScript
4508 lines
180 KiB
JavaScript
const lmeState = {
|
||
map: null,
|
||
baseLayers: {},
|
||
currentBase: null,
|
||
currentBaseName: "satellite",
|
||
layerOrder: [
|
||
"gpsPosition",
|
||
"drawings",
|
||
"gpsTrail",
|
||
"gpsParcels",
|
||
"selectedParcels",
|
||
"importedSheets",
|
||
"imported",
|
||
"cadastre"
|
||
],
|
||
cadastre: null,
|
||
cadastreLayers: ["CP.CadastralParcel", "codice_plla", "CP.CadastralZoning", "fabbricati"],
|
||
cadastreOpacity: 0.75,
|
||
imported: null,
|
||
importedVisible: true,
|
||
importedSheets: null,
|
||
importedSheetsVisible: false,
|
||
importedMeasurements: null,
|
||
drawings: null,
|
||
drawingsRenderer: null,
|
||
drawingSelection: null,
|
||
drawingMeasurements: null,
|
||
drawControl: null,
|
||
drawingDeleteButton: null,
|
||
drawingScopeMenu: null,
|
||
ignoreMapClickUntil: 0,
|
||
activeDrawHandler: null,
|
||
drawEventsBound: false,
|
||
includeKmlSheetLayer: false,
|
||
showImportedMeasurements: false,
|
||
showDrawingMeasurements: true,
|
||
measurementRefreshTimer: null,
|
||
gpsMarker: null,
|
||
gpsAccuracy: null,
|
||
gpsPositionVisible: true,
|
||
hasLiveGpsFix: false,
|
||
lastAccuracy: 0,
|
||
gpsTrail: null,
|
||
selectedParcelsLayer: null,
|
||
selectedParcelsVisible: true,
|
||
gpsParcelLayer: null,
|
||
gpsTrailEnabled: false,
|
||
gpsTrailPoints: [],
|
||
gpsWatchId: null,
|
||
lastPosition: null,
|
||
fileImportBound: false,
|
||
resizeBound: false,
|
||
googleMapsLoading: null,
|
||
azureMapsKey: null,
|
||
azureAttribution: null,
|
||
managerHighlight: null,
|
||
managerHighlightedObjectId: null,
|
||
managerHighlightedCategoryKey: null,
|
||
managerHighlightedParentKey: null,
|
||
managerHighlightedParentRecords: null,
|
||
managerHoveredParentKey: null,
|
||
managerHoveredParentRecords: null,
|
||
managerOutsideSelectionBound: false,
|
||
managerHiddenObjects: new Map(),
|
||
managerHiddenObjectIds: new Set(),
|
||
managerHiddenMeasurementSummaryIds: new Set(),
|
||
managerHiddenMeasurementSideIds: new Set(),
|
||
managerHiddenCategories: new Set(),
|
||
managerRemovedCategories: new Set(),
|
||
managerObjectOrder: {},
|
||
managerCategoryNames: {},
|
||
managerExpandedRows: new Set(),
|
||
managerSelectedObjectIds: new Set(),
|
||
managerSearchText: "",
|
||
permanentLabels: null,
|
||
undoStack: [],
|
||
redoStack: [],
|
||
historyBusy: false,
|
||
snapEnabled: true,
|
||
snapDistancePixels: 14,
|
||
autoSaveEnabled: true,
|
||
gpsProximityEnabled: true,
|
||
gpsBoundaryAlertEnabled: false,
|
||
lastBoundaryState: null,
|
||
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,
|
||
restoreAutosave,
|
||
compareProjectSnapshots,
|
||
setAdvancedOptions,
|
||
createMapExportGeoJson,
|
||
createProjectSnapshot,
|
||
loadProjectSnapshot,
|
||
getLegacyProjectSnapshot,
|
||
clearLegacyProjectSnapshot,
|
||
setBaseLayer,
|
||
configureGoogleMaps,
|
||
configureAzureMaps,
|
||
clearAzureMaps,
|
||
setCadastreOpacity,
|
||
toggleCadastreLayer,
|
||
startGps,
|
||
stopGps,
|
||
centerOnGps,
|
||
toggleTrail,
|
||
setNativeGpsPosition,
|
||
setGpsPositionVisible,
|
||
setGpsError,
|
||
saveGpsMeasurementPoint,
|
||
resize,
|
||
showInfo: setInfo
|
||
};
|
||
|
||
function init(dotNetReference) {
|
||
if (dotNetReference) {
|
||
lmeState.dotNetReference = dotNetReference;
|
||
}
|
||
if (lmeState.map) {
|
||
return;
|
||
}
|
||
|
||
lmeState.map = L.map("map", {
|
||
zoomControl: false
|
||
}).setView([41.9028, 12.4964], 17);
|
||
|
||
L.control.zoom({ position: "bottomright" }).addTo(lmeState.map);
|
||
|
||
lmeState.baseLayers.osm = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||
maxNativeZoom: 19,
|
||
maxZoom: 22,
|
||
attribution: "© OpenStreetMap contributors"
|
||
});
|
||
|
||
lmeState.baseLayers.satellite = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", {
|
||
maxNativeZoom: 19,
|
||
maxZoom: 22,
|
||
attribution: "Tiles © Esri"
|
||
});
|
||
|
||
lmeState.baseLayers.topographic = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", {
|
||
maxNativeZoom: 19,
|
||
maxZoom: 22,
|
||
attribution: "Tiles © Esri"
|
||
});
|
||
|
||
lmeState.baseLayers["streets-esri"] = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}", {
|
||
maxNativeZoom: 19,
|
||
maxZoom: 22,
|
||
attribution: "Tiles © Esri"
|
||
});
|
||
|
||
lmeState.baseLayers.terrain = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", {
|
||
maxNativeZoom: 17,
|
||
maxZoom: 22,
|
||
attribution: "Map data © OpenStreetMap contributors, SRTM | Map style © OpenTopoMap"
|
||
});
|
||
|
||
lmeState.baseLayers.carto = L.tileLayer("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", {
|
||
subdomains: "abcd",
|
||
maxNativeZoom: 20,
|
||
maxZoom: 22,
|
||
attribution: "© OpenStreetMap contributors © CARTO"
|
||
});
|
||
lmeState.baseLayers.offline = L.gridLayer({ attribution: "Sfondo offline" });
|
||
lmeState.baseLayers.offline.createTile = () => {
|
||
const tile = document.createElement("div");
|
||
tile.className = "lme-offline-tile";
|
||
return tile;
|
||
};
|
||
|
||
lmeState.currentBase = lmeState.baseLayers.satellite.addTo(lmeState.map);
|
||
restoreLayerOrder();
|
||
Object.values(managedLayerPanes()).forEach(paneName => {
|
||
if (!lmeState.map.getPane(paneName)) {
|
||
lmeState.map.createPane(paneName);
|
||
}
|
||
});
|
||
const managerHighlightPane = lmeState.map.createPane("managerHighlightPane");
|
||
managerHighlightPane.style.zIndex = "650";
|
||
lmeState.drawingsRenderer = L.canvas({ pane: "drawingsPane", tolerance: 14 });
|
||
setLayerOrder(lmeState.layerOrder);
|
||
lmeState.imported = L.featureGroup().addTo(lmeState.map);
|
||
lmeState.importedSheets = L.featureGroup();
|
||
lmeState.drawings = L.featureGroup().addTo(lmeState.map);
|
||
lmeState.drawingSelection = L.featureGroup();
|
||
lmeState.importedMeasurements = L.layerGroup().addTo(lmeState.map);
|
||
lmeState.drawingMeasurements = L.layerGroup().addTo(lmeState.map);
|
||
lmeState.selectedParcelsLayer = L.featureGroup().addTo(lmeState.map);
|
||
lmeState.gpsParcelLayer = L.featureGroup().addTo(lmeState.map);
|
||
lmeState.gpsTrail = L.polyline([], {
|
||
pane: "gpsTrailPane",
|
||
color: "#f97316",
|
||
weight: 4,
|
||
opacity: 0.85
|
||
}).addTo(lmeState.map);
|
||
lmeState.managerHighlight = L.layerGroup().addTo(lmeState.map);
|
||
lmeState.permanentLabels = L.layerGroup().addTo(lmeState.map);
|
||
|
||
refreshCadastreLayer();
|
||
bindFileImport();
|
||
bindDrawingEvents();
|
||
|
||
if (!lmeState.resizeBound) {
|
||
window.addEventListener("resize", resize);
|
||
window.addEventListener("orientationchange", resize);
|
||
lmeState.resizeBound = true;
|
||
}
|
||
|
||
lmeState.map.on("click", async event => {
|
||
if (event.originalEvent?._lmeDrawingSelected || Date.now() < lmeState.ignoreMapClickUntil) {
|
||
return;
|
||
}
|
||
hideDrawingScopeMenu();
|
||
clearManagerHighlight();
|
||
const info = await queryFeatureInfo(event.latlng);
|
||
if (info) {
|
||
L.popup().setLatLng(event.latlng).setContent(info).openOn(lmeState.map);
|
||
}
|
||
});
|
||
lmeState.map.on("moveend", () => {
|
||
void updateAzureAttribution();
|
||
scheduleMeasurementRefresh();
|
||
});
|
||
|
||
document.getElementById("closeWmsPreview")?.addEventListener("click", closeCadastrePreview);
|
||
document.getElementById("wmsPreview")?.addEventListener("click", event => {
|
||
if (event.target?.id === "wmsPreview") {
|
||
closeCadastrePreview();
|
||
}
|
||
});
|
||
document.addEventListener("keydown", event => {
|
||
if (event.key === "Escape") {
|
||
closeCadastrePreview();
|
||
}
|
||
});
|
||
if (!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 {
|
||
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" : "";
|
||
setInfo(`Caricato ${file.name}: ${featureCount} particelle/elementi${includedSheets}${ignored}${reprojected}.`);
|
||
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)})`);
|
||
}
|
||
|
||
const normalizedCoordinates = collectGeoJsonCoordinates(root);
|
||
if (!normalizedCoordinates.length || normalizedCoordinates.some(point =>
|
||
!Number.isFinite(point[0])
|
||
|| !Number.isFinite(point[1])
|
||
|| Math.abs(point[0]) > 180
|
||
|| Math.abs(point[1]) > 90)) {
|
||
throw new Error("coordinate non valide o non espresse in WGS84/EPSG:4326");
|
||
}
|
||
|
||
delete root.crs;
|
||
return {
|
||
geojson: root,
|
||
sheetGeojson: emptyFeatureCollection(),
|
||
ignoredCount: 0,
|
||
reprojected: webMercator
|
||
};
|
||
}
|
||
|
||
function isGeoJsonGeometry(value) {
|
||
return [
|
||
"Point",
|
||
"MultiPoint",
|
||
"LineString",
|
||
"MultiLineString",
|
||
"Polygon",
|
||
"MultiPolygon",
|
||
"GeometryCollection"
|
||
].includes(value?.type);
|
||
}
|
||
|
||
function collectGeoJsonCoordinates(geojson) {
|
||
const points = [];
|
||
const visitGeometry = geometry => {
|
||
if (!geometry) {
|
||
return;
|
||
}
|
||
if (geometry.type === "GeometryCollection") {
|
||
(geometry.geometries || []).forEach(visitGeometry);
|
||
return;
|
||
}
|
||
const visit = coordinates => {
|
||
if (Array.isArray(coordinates)
|
||
&& coordinates.length >= 2
|
||
&& typeof coordinates[0] === "number"
|
||
&& typeof coordinates[1] === "number") {
|
||
points.push(coordinates);
|
||
return;
|
||
}
|
||
if (Array.isArray(coordinates)) {
|
||
coordinates.forEach(visit);
|
||
}
|
||
};
|
||
visit(geometry.coordinates);
|
||
};
|
||
(geojson.features || []).forEach(feature => visitGeometry(feature.geometry));
|
||
return points;
|
||
}
|
||
|
||
function transformGeoJsonGeometry(geometry, transform) {
|
||
if (!geometry) {
|
||
return geometry;
|
||
}
|
||
if (geometry.type === "GeometryCollection") {
|
||
return {
|
||
...geometry,
|
||
geometries: (geometry.geometries || []).map(item => transformGeoJsonGeometry(item, transform))
|
||
};
|
||
}
|
||
const visit = coordinates => {
|
||
if (Array.isArray(coordinates)
|
||
&& coordinates.length >= 2
|
||
&& typeof coordinates[0] === "number"
|
||
&& typeof coordinates[1] === "number") {
|
||
return transform(coordinates);
|
||
}
|
||
return Array.isArray(coordinates) ? coordinates.map(visit) : coordinates;
|
||
};
|
||
return { ...geometry, coordinates: visit(geometry.coordinates) };
|
||
}
|
||
|
||
function webMercatorToWgs84(coordinate) {
|
||
const longitude = coordinate[0] * 180 / 20037508.34;
|
||
const latitudeRadians = Math.PI / 2 - 2 * Math.atan(Math.exp(-coordinate[1] / 6378137));
|
||
return [longitude, latitudeRadians * 180 / Math.PI, ...coordinate.slice(2)];
|
||
}
|
||
|
||
function normalizeKmlFiles(files) {
|
||
const features = files.flatMap(file => kmlTextToGeoJson(file.text, file.name));
|
||
if (!features.length) {
|
||
throw new Error("KML senza geometrie utilizzabili");
|
||
}
|
||
|
||
const polygonParts = splitKmlPolygonFeatures(features);
|
||
const parcels = filterKmlParcelFeatures(polygonParts);
|
||
const parcelSet = new Set(parcels);
|
||
const sheetParts = polygonParts.filter(feature => !parcelSet.has(feature));
|
||
const nonPolygonFeatures = features.filter(feature => geometryPolygons(feature.geometry).length === 0);
|
||
const optionalFeatures = [...sheetParts, ...nonPolygonFeatures];
|
||
if (!parcels.length && !(lmeState.includeKmlSheetLayer && optionalFeatures.length)) {
|
||
throw new Error("nessuna particella trovata; attiva 'includi layer del foglio' per importare gli altri elementi");
|
||
}
|
||
return {
|
||
geojson: { type: "FeatureCollection", features: parcels },
|
||
sheetGeojson: {
|
||
type: "FeatureCollection",
|
||
features: lmeState.includeKmlSheetLayer ? optionalFeatures : []
|
||
},
|
||
ignoredCount: lmeState.includeKmlSheetLayer ? 0 : optionalFeatures.length,
|
||
reprojected: false
|
||
};
|
||
}
|
||
|
||
function kmlTextToGeoJson(text, sourceName) {
|
||
const xml = new DOMParser().parseFromString(text, "text/xml");
|
||
if (xml.getElementsByTagName("parsererror").length) {
|
||
throw new Error(`KML non valido (${sourceName})`);
|
||
}
|
||
const tree = toGeoJSON.kmlWithFolders(xml, { skipNullGeometry: true });
|
||
const features = [];
|
||
const visit = (node, folders) => {
|
||
if (node?.type === "Feature") {
|
||
const sourceProperties = node.properties || {};
|
||
node.properties = {
|
||
...sourceProperties,
|
||
_lmeColor: sourceProperties._lmeColor || sourceProperties.stroke,
|
||
_lmeFillColor: sourceProperties._lmeFillColor || sourceProperties.fill,
|
||
_lmeWeight: sourceProperties._lmeWeight || sourceProperties["stroke-width"],
|
||
_lmeOpacity: sourceProperties._lmeOpacity || sourceProperties["stroke-opacity"],
|
||
_lmeFillOpacity: sourceProperties._lmeFillOpacity || sourceProperties["fill-opacity"],
|
||
_lmeFolder: folders.filter(Boolean).join(" / "),
|
||
_lmeSource: sourceName
|
||
};
|
||
features.push(node);
|
||
return;
|
||
}
|
||
const nextFolders = node?.type === "folder"
|
||
? [...folders, node.meta?.name || ""]
|
||
: folders;
|
||
(node?.children || []).forEach(child => visit(child, nextFolders));
|
||
};
|
||
visit(tree, []);
|
||
return features;
|
||
}
|
||
|
||
function filterKmlParcelFeatures(features) {
|
||
const filtered = features.filter(feature => {
|
||
if (isSheetOrOverlayFeature(feature)) {
|
||
return false;
|
||
}
|
||
const properties = feature.properties || {};
|
||
const name = String(properties.name || properties.Name || properties.LABEL || "").trim();
|
||
const identity = [name, properties.layer, properties.Layer, properties.type, properties.Tipo]
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
.toLowerCase();
|
||
const folder = String(properties._lmeFolder || "").toLowerCase();
|
||
const context = `${identity} ${folder}`;
|
||
const hasParcelNumber = /^[a-z]?\s*\d+[a-z]?(?:[\/. -]\d+[a-z]?)?$/i.test(name);
|
||
const isSheet = /\bfoglio\b|\bsheet\b|quadro[ _-]?d[' ]?unione|(?:perimetro|bordo|limite|contorno)[ _-]?(?:del[ _-]?)?foglio|sheet[ _-]?boundary/.test(context);
|
||
return !isSheet || hasParcelNumber;
|
||
});
|
||
return removeLikelySheetOutlines(filtered);
|
||
}
|
||
|
||
function isSheetOrOverlayFeature(feature) {
|
||
const properties = feature?.properties || {};
|
||
const geometryType = String(properties["@geometry-type"] || "").toLowerCase();
|
||
const name = String(properties.name || properties.Name || properties.LABEL || "").trim();
|
||
const context = [
|
||
name,
|
||
properties.layer,
|
||
properties.Layer,
|
||
properties.type,
|
||
properties.Tipo,
|
||
properties._lmeFolder
|
||
].filter(Boolean).join(" ").toLowerCase();
|
||
|
||
if (geometryType === "groundoverlay" || /^doc[_ -]?\d+$/i.test(name)) {
|
||
return true;
|
||
}
|
||
|
||
const hasParcelNumber = /^[a-z]?\s*\d+[a-z]?(?:[\/. -]\d+[a-z]?)?$/i.test(name);
|
||
return !hasParcelNumber
|
||
&& /\bfoglio\b|\bsheet\b|quadro[ _-]?d[' ]?unione|(?:perimetro|bordo|limite|contorno)[ _-]?(?:del[ _-]?)?foglio|sheet[ _-]?boundary/.test(context);
|
||
}
|
||
|
||
function splitKmlPolygonFeatures(features) {
|
||
const result = [];
|
||
const addGeometry = (feature, geometry, partIndex) => {
|
||
if (!geometry) {
|
||
return;
|
||
}
|
||
if (geometry.type === "Polygon") {
|
||
result.push({
|
||
...feature,
|
||
properties: { ...(feature.properties || {}), _lmePart: partIndex },
|
||
geometry
|
||
});
|
||
return;
|
||
}
|
||
if (geometry.type === "MultiPolygon") {
|
||
(geometry.coordinates || []).forEach((coordinates, index) => addGeometry(
|
||
feature,
|
||
{ type: "Polygon", coordinates },
|
||
`${partIndex}.${index}`));
|
||
return;
|
||
}
|
||
if (geometry.type === "GeometryCollection") {
|
||
(geometry.geometries || []).forEach((item, index) => addGeometry(feature, item, `${partIndex}.${index}`));
|
||
}
|
||
};
|
||
features.forEach((feature, index) => addGeometry(feature, feature.geometry, index));
|
||
return result;
|
||
}
|
||
|
||
function removeLikelySheetOutlines(features) {
|
||
let remaining = [...features];
|
||
while (remaining.length >= 2) {
|
||
const candidates = remaining
|
||
.map(feature => ({ feature, bounds: geoJsonBounds(feature.geometry) }))
|
||
.filter(item => item.bounds);
|
||
if (candidates.length < 2) {
|
||
break;
|
||
}
|
||
|
||
const largest = candidates.reduce((current, item) =>
|
||
boundsArea(item.bounds) > boundsArea(current.bounds) ? item : current);
|
||
if (isStrongParcelFeature(largest.feature)) {
|
||
break;
|
||
}
|
||
const others = candidates.filter(item => item !== largest);
|
||
const comparisonAreas = others.map(item => boundsArea(item.bounds)).sort((a, b) => a - b);
|
||
const referenceArea = comparisonAreas[Math.floor(comparisonAreas.length / 2)];
|
||
const contained = others.filter(item => boundsContains(largest.bounds, boundsCenter(item.bounds))).length;
|
||
const minimumContained = candidates.length === 2 ? 1 : Math.ceil(others.length * 0.6);
|
||
const areaFactor = 2.5;
|
||
const looksLikeSheet = referenceArea > 0
|
||
&& boundsArea(largest.bounds) > referenceArea * areaFactor
|
||
&& contained >= minimumContained;
|
||
if (!looksLikeSheet) {
|
||
break;
|
||
}
|
||
remaining = remaining.filter(feature => feature !== largest.feature);
|
||
}
|
||
return remaining;
|
||
}
|
||
|
||
function isStrongParcelFeature(feature) {
|
||
const properties = feature?.properties || {};
|
||
const name = String(properties.name || properties.Name || properties.LABEL || "").trim();
|
||
const context = [name, properties.layer, properties.Layer, properties.type, properties.Tipo, properties._lmeFolder]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
return /particell|mappal|parcel/i.test(context)
|
||
|| /^[a-z]?\s*\d+[a-z]?(?:[\/. -]\d+[a-z]?)?$/i.test(name);
|
||
}
|
||
|
||
function geoJsonBounds(geometry) {
|
||
const wrapper = { features: [{ geometry }] };
|
||
const points = collectGeoJsonCoordinates(wrapper);
|
||
if (!points.length) {
|
||
return null;
|
||
}
|
||
return points.reduce((bounds, point) => ({
|
||
west: Math.min(bounds.west, point[0]),
|
||
south: Math.min(bounds.south, point[1]),
|
||
east: Math.max(bounds.east, point[0]),
|
||
north: Math.max(bounds.north, point[1])
|
||
}), { west: Infinity, south: Infinity, east: -Infinity, north: -Infinity });
|
||
}
|
||
|
||
function boundsArea(bounds) {
|
||
return Math.max(0, bounds.east - bounds.west) * Math.max(0, bounds.north - bounds.south);
|
||
}
|
||
|
||
function boundsCenter(bounds) {
|
||
return [(bounds.west + bounds.east) / 2, (bounds.south + bounds.north) / 2];
|
||
}
|
||
|
||
function boundsContains(bounds, point) {
|
||
return point[0] >= bounds.west && point[0] <= bounds.east
|
||
&& point[1] >= bounds.south && point[1] <= bounds.north;
|
||
}
|
||
|
||
function setIncludeKmlSheetLayer(enabled) {
|
||
lmeState.includeKmlSheetLayer = Boolean(enabled);
|
||
setInfo(lmeState.includeKmlSheetLayer
|
||
? "Importazione KML/KMZ: verranno inclusi anche foglio e altri elementi."
|
||
: "Importazione KML/KMZ: verranno caricate solo le particelle riconosciute.");
|
||
}
|
||
|
||
function setImportedLayerVisible(enabled) {
|
||
lmeState.importedVisible = Boolean(enabled);
|
||
if (!lmeState.map || !lmeState.imported) {
|
||
return;
|
||
}
|
||
|
||
if (lmeState.importedVisible) {
|
||
if (!lmeState.map.hasLayer(lmeState.imported)) {
|
||
lmeState.imported.addTo(lmeState.map);
|
||
}
|
||
lmeState.imported.bringToFront();
|
||
const count = lmeState.imported.toGeoJSON().features?.length || 0;
|
||
setInfo(count
|
||
? `${count} particelle o geometrie importate visibili.`
|
||
: "Nessun dato importato da mostrare.");
|
||
return;
|
||
}
|
||
|
||
if (lmeState.map.hasLayer(lmeState.imported)) {
|
||
lmeState.map.removeLayer(lmeState.imported);
|
||
}
|
||
setInfo("Dati importati nascosti; restano disponibili nel file o progetto.");
|
||
}
|
||
|
||
function managedLayerPanes() {
|
||
return {
|
||
gpsPosition: "gpsPositionPane",
|
||
drawings: "drawingsPane",
|
||
gpsTrail: "gpsTrailPane",
|
||
gpsParcels: "gpsParcelsPane",
|
||
selectedParcels: "selectedParcelsPane",
|
||
importedSheets: "importedSheetPane",
|
||
imported: "importedPane",
|
||
cadastre: "cadastrePane"
|
||
};
|
||
}
|
||
|
||
function setLayerOrder(order) {
|
||
const panes = managedLayerPanes();
|
||
const requested = Array.isArray(order) ? order.filter(key => panes[key]) : [];
|
||
const normalized = [...new Set([...requested, ...Object.keys(panes)])];
|
||
lmeState.layerOrder = normalized;
|
||
if (!lmeState.map) {
|
||
return;
|
||
}
|
||
normalized.forEach((key, index) => {
|
||
const pane = lmeState.map.getPane(panes[key]);
|
||
if (pane) {
|
||
pane.style.zIndex = String(590 - index * 20);
|
||
}
|
||
});
|
||
try {
|
||
localStorage.setItem("lmeMapLayerOrder", JSON.stringify(normalized));
|
||
} catch {
|
||
}
|
||
}
|
||
|
||
function restoreLayerOrder() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem("lmeMapLayerOrder") || "null");
|
||
if (Array.isArray(saved)) {
|
||
lmeState.layerOrder = saved;
|
||
}
|
||
} catch {
|
||
}
|
||
}
|
||
|
||
function saveLayerManagerState() {
|
||
// Object order and visibility are serialized in the project snapshot.
|
||
}
|
||
|
||
function applyStoredLayerManagerVisibility() {
|
||
layerManagerCategories().forEach(category => {
|
||
if (lmeState.managerHiddenCategories.has(category.key)) {
|
||
category.roots.forEach(layer => {
|
||
if (layer && lmeState.map.hasLayer(layer)) {
|
||
lmeState.map.removeLayer(layer);
|
||
}
|
||
});
|
||
}
|
||
managedRecordsForCategory(category).forEach(record => {
|
||
if (lmeState.managerHiddenObjectIds.has(record.id)
|
||
&& !lmeState.managerHiddenObjects.has(record.id)) {
|
||
lmeState.managerHiddenObjects.set(record.id, record);
|
||
record.parent.removeLayer(record.layer);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function managedGeoJsonLabel(geojson, fallback) {
|
||
const features = geojson?.features || [];
|
||
const source = features.map(feature => feature?.properties?._lmeSource).find(Boolean);
|
||
return `${source || fallback} (${features.length})`;
|
||
}
|
||
|
||
function drawingLayerLabel(layer, layerType) {
|
||
const properties = layer?.feature?.properties || {};
|
||
if (properties.name) {
|
||
return `POI: ${properties.name}`;
|
||
}
|
||
const shape = layerType || properties._lmeShape || "disegno";
|
||
const labels = {
|
||
polygon: "Area",
|
||
rectangle: "Rettangolo",
|
||
polyline: "Linea",
|
||
circle: "Cerchio",
|
||
marker: "POI"
|
||
};
|
||
return labels[shape] || "Disegno";
|
||
}
|
||
|
||
function layerManagerCategories() {
|
||
return [
|
||
{ key: "gpsPosition", label: "Posizione GPS", roots: [lmeState.gpsMarker, lmeState.gpsAccuracy].filter(Boolean) },
|
||
{ key: "drawings", label: "Disegni e POI", roots: [lmeState.drawings].filter(Boolean) },
|
||
{ key: "gpsTrail", label: "Traccia GPS", roots: [lmeState.gpsTrail].filter(Boolean) },
|
||
{ key: "gpsParcels", label: "Particella rilevata dal GPS", roots: [lmeState.gpsParcelLayer].filter(Boolean) },
|
||
{ key: "selectedParcels", label: "Particelle catastali evidenziate", roots: [lmeState.selectedParcelsLayer].filter(Boolean) },
|
||
{ key: "importedSheets", label: "Cornici e fogli importati", roots: [lmeState.importedSheets].filter(Boolean), blocks: true },
|
||
{ key: "imported", label: "Dati importati", roots: [lmeState.imported].filter(Boolean), blocks: true },
|
||
{ key: "cadastre", label: "Layer catastali AdE", roots: [lmeState.cadastre].filter(Boolean) }
|
||
];
|
||
}
|
||
|
||
function managedLayerId(layer, categoryKey) {
|
||
const properties = layer?.feature?.properties;
|
||
if (properties) {
|
||
properties._lmeManagerId ||= `${categoryKey}-${L.stamp(layer)}`;
|
||
return properties._lmeManagerId;
|
||
}
|
||
layer.lmeManagerId ||= `${categoryKey}-${L.stamp(layer)}`;
|
||
return layer.lmeManagerId;
|
||
}
|
||
|
||
function collectManagedLeafLayers(layer, categoryKey, parent, records) {
|
||
if (!layer) {
|
||
return;
|
||
}
|
||
if (typeof layer.getLayers === "function") {
|
||
const children = layer.getLayers();
|
||
children.forEach(child => collectManagedLeafLayers(child, categoryKey, layer, records));
|
||
return;
|
||
}
|
||
if (layer instanceof L.Polyline && !lineLatLngGroups(layer.getLatLngs()).some(line => line.length)) {
|
||
return;
|
||
}
|
||
const id = managedLayerId(layer, categoryKey);
|
||
records.push({
|
||
id,
|
||
categoryKey,
|
||
layer,
|
||
parent,
|
||
source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente")
|
||
});
|
||
}
|
||
|
||
function managedRecordsForCategory(category) {
|
||
const records = [];
|
||
category.roots.forEach(root => collectManagedLeafLayers(root, category.key, lmeState.map, records));
|
||
lmeState.managerHiddenObjects.forEach(record => {
|
||
if (record.categoryKey === category.key && !records.some(item => item.id === record.id)) {
|
||
records.push(record);
|
||
}
|
||
});
|
||
return records;
|
||
}
|
||
|
||
function managedObjectLabel(record, index) {
|
||
const layer = record.layer;
|
||
const properties = layer?.feature?.properties || {};
|
||
const reference = properties.NATIONALCADASTRALREFERENCE
|
||
|| properties.INSPIREID_LOCALID
|
||
|| properties.LABEL
|
||
|| properties.name
|
||
|| properties.Name;
|
||
if (reference) {
|
||
return String(reference);
|
||
}
|
||
if (layer.lmeManagerLabel) {
|
||
return layer.lmeManagerLabel;
|
||
}
|
||
if (layer instanceof L.Circle) {
|
||
return `Cerchio ${index + 1}`;
|
||
}
|
||
if (layer instanceof L.Polygon) {
|
||
return `Area/particella ${index + 1}`;
|
||
}
|
||
if (layer instanceof L.Polyline) {
|
||
return `Linea ${index + 1}`;
|
||
}
|
||
if (layer instanceof L.Marker) {
|
||
return `POI ${index + 1}`;
|
||
}
|
||
return `Oggetto ${index + 1}`;
|
||
}
|
||
|
||
function managedObjectEditableName(record) {
|
||
const properties = record?.layer?.feature?.properties || {};
|
||
return properties.name || properties.Name || properties.label || properties.LABEL || "";
|
||
}
|
||
|
||
function managedObjectCanRename(record) {
|
||
return ["drawings", "imported", "importedSheets"].includes(record.categoryKey)
|
||
&& record.layer?.feature?.properties;
|
||
}
|
||
|
||
function renameManagedObject(record, index) {
|
||
if (!managedObjectCanRename(record)) {
|
||
return;
|
||
}
|
||
const currentName = String(managedObjectEditableName(record) || managedObjectLabel(record, index)).trim();
|
||
const requestedName = window.prompt("Nuovo nome oggetto", currentName);
|
||
if (requestedName === null) {
|
||
return;
|
||
}
|
||
const name = requestedName.trim().slice(0, 120);
|
||
if (!name) {
|
||
setInfo("Nome non modificato: inserisci un testo.");
|
||
return;
|
||
}
|
||
rememberMapChange();
|
||
const properties = record.layer.feature.properties;
|
||
properties.name = name;
|
||
record.layer.lmeManagerLabel = record.categoryKey === "drawings" && record.layer instanceof L.Marker
|
||
? `POI: ${name}`
|
||
: null;
|
||
updateStoredFeaturePopup(record.layer);
|
||
refreshImportedMeasurements();
|
||
refreshDrawingMeasurements();
|
||
refreshPermanentLabels();
|
||
refreshLayerManager();
|
||
finishMapChange();
|
||
setInfo(`Oggetto rinominato: ${name}.`);
|
||
}
|
||
|
||
function managedCategoryName(category, records) {
|
||
const storedName = lmeState.managerCategoryNames[category.key];
|
||
const featureName = records
|
||
.map(record => record.layer?.feature?.properties?._lmeCategoryName)
|
||
.find(Boolean);
|
||
const name = String(storedName || featureName || category.label).trim();
|
||
if (name !== category.label) {
|
||
lmeState.managerCategoryNames[category.key] = name;
|
||
}
|
||
return name;
|
||
}
|
||
|
||
function managedCategoryCanRename(category) {
|
||
return ["drawings", "imported", "importedSheets"].includes(category.key);
|
||
}
|
||
|
||
function renameManagedCategory(category, records) {
|
||
const currentName = managedCategoryName(category, records);
|
||
const requestedName = window.prompt("Nuovo nome gruppo", currentName);
|
||
if (requestedName === null) {
|
||
return;
|
||
}
|
||
const name = requestedName.trim().slice(0, 120);
|
||
if (!name) {
|
||
setInfo("Nome non modificato: inserisci un testo.");
|
||
return;
|
||
}
|
||
rememberMapChange();
|
||
lmeState.managerCategoryNames[category.key] = name;
|
||
records.forEach(record => {
|
||
if (record.layer?.feature?.properties) {
|
||
record.layer.feature.properties._lmeCategoryName = name;
|
||
}
|
||
});
|
||
refreshLayerManager();
|
||
finishMapChange();
|
||
setInfo(`Gruppo rinominato: ${name}.`);
|
||
}
|
||
|
||
function managedBlockName(source, records) {
|
||
return String(records
|
||
.map(record => record.layer?.feature?.properties?._lmeGroupName)
|
||
.find(Boolean) || source);
|
||
}
|
||
|
||
function renameManagedBlock(category, source, records) {
|
||
const currentName = managedBlockName(source, records);
|
||
const requestedName = window.prompt("Nuovo nome gruppo", currentName);
|
||
if (requestedName === null) {
|
||
return;
|
||
}
|
||
const name = requestedName.trim().slice(0, 120);
|
||
if (!name) {
|
||
setInfo("Nome non modificato: inserisci un testo.");
|
||
return;
|
||
}
|
||
rememberMapChange();
|
||
records.forEach(record => {
|
||
if (record.layer?.feature?.properties) {
|
||
record.layer.feature.properties._lmeGroupName = name;
|
||
}
|
||
});
|
||
refreshLayerManager();
|
||
finishMapChange();
|
||
setInfo(`Gruppo rinominato: ${name}.`);
|
||
}
|
||
|
||
function managedCategoryCanRemove(category, records) {
|
||
return records.length > 0
|
||
&& ["drawings", "imported", "importedSheets", "selectedParcels", "gpsParcels"].includes(category.key);
|
||
}
|
||
|
||
function managedObjectCanRemove(record) {
|
||
return ["drawings", "imported", "importedSheets", "selectedParcels", "gpsParcels"].includes(record.categoryKey);
|
||
}
|
||
|
||
function layerManagerRowActions(renameButton, removeButton) {
|
||
if (!renameButton && !removeButton) {
|
||
return "";
|
||
}
|
||
return `<span class="layer-row-actions">${renameButton}${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 (!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();
|
||
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();
|
||
setInfo(`${records.length === 1 ? "Oggetto rimosso" : `${records.length} oggetti rimossi`} dal progetto.`);
|
||
}
|
||
|
||
function managedObjectDetails(layer) {
|
||
const details = [];
|
||
const properties = layer?.feature?.properties || {};
|
||
if (properties.name || properties.Name) {
|
||
details.push(`Nome: ${properties.name || properties.Name}`);
|
||
}
|
||
if (layer instanceof L.Circle) {
|
||
const radius = layer.getRadius();
|
||
details.push(`Raggio: ${formatDistance(radius)}`);
|
||
details.push(`Superficie: ${formatArea(Math.PI * radius * radius)}`);
|
||
details.push(`Circonferenza: ${formatDistance(2 * Math.PI * radius)}`);
|
||
} else if (layer instanceof L.Polygon) {
|
||
polygonLatLngGroups(layer.getLatLngs()).forEach((polygon, polygonIndex) => {
|
||
const outer = polygon[0] || [];
|
||
const area = polygon.reduce((sum, ring, ringIndex) => {
|
||
const ringArea = Math.abs(L.GeometryUtil.geodesicArea(ring));
|
||
return sum + (ringIndex === 0 ? ringArea : -ringArea);
|
||
}, 0);
|
||
details.push(`${polygonIndex ? `Area ${polygonIndex + 1}` : "Superficie"}: ${formatArea(Math.abs(area))}`);
|
||
details.push(`${polygonIndex ? `Perimetro ${polygonIndex + 1}` : "Perimetro"}: ${formatDistance(latLngPathLength(outer, true))}`);
|
||
outer.forEach((point, index) => {
|
||
const next = outer[(index + 1) % outer.length];
|
||
if (next && !point.equals(next)) {
|
||
details.push(`Lato ${index + 1}: ${formatDistance(lmeState.map.distance(point, next))}`);
|
||
}
|
||
});
|
||
});
|
||
} else if (layer instanceof L.Polyline) {
|
||
const lines = lineLatLngGroups(layer.getLatLngs());
|
||
details.push(`Lunghezza: ${formatDistance(lines.reduce((sum, line) => sum + latLngPathLength(line, false), 0))}`);
|
||
} else if (typeof layer.getLatLng === "function") {
|
||
const point = layer.getLatLng();
|
||
details.push(`Coordinate: ${point.lat.toFixed(6)}, ${point.lng.toFixed(6)}`);
|
||
}
|
||
return details;
|
||
}
|
||
|
||
function refreshLayerManager() {
|
||
const container = document.getElementById("layerManagerTree");
|
||
if (!container || !lmeState.map) {
|
||
return;
|
||
}
|
||
captureLayerManagerExpansion(container);
|
||
const scrollHost = container.closest(".map-layer-panel");
|
||
const scrollTop = scrollHost?.scrollTop || 0;
|
||
applyStoredLayerManagerVisibility();
|
||
container.innerHTML = "";
|
||
lmeState.managerRecords = new Map();
|
||
container.appendChild(renderLayerSelectionToolbar());
|
||
const categoriesByKey = new Map(layerManagerCategories().map(category => [category.key, category]));
|
||
lmeState.layerOrder.forEach(categoryKey => {
|
||
const category = categoriesByKey.get(categoryKey);
|
||
if (!category) {
|
||
return;
|
||
}
|
||
const records = managedRecordsForCategory(category);
|
||
if (records.length) {
|
||
lmeState.managerRemovedCategories.delete(category.key);
|
||
} else if (lmeState.managerRemovedCategories.has(category.key)) {
|
||
return;
|
||
}
|
||
const savedOrder = lmeState.managerObjectOrder[category.key] || [];
|
||
const positions = new Map(savedOrder.map((id, index) => [id, index]));
|
||
records.sort((left, right) =>
|
||
(positions.get(left.id) ?? Number.MAX_SAFE_INTEGER) - (positions.get(right.id) ?? Number.MAX_SAFE_INTEGER));
|
||
records.forEach(record => lmeState.managerRecords.set(record.id, record));
|
||
const rendered = renderLayerManagerCategory(category, records);
|
||
const search = lmeState.managerSearchText.trim().toLocaleLowerCase("it");
|
||
if (search && !records.some((record, index) => `${managedObjectLabel(record, index)} ${record.source}`.toLocaleLowerCase("it").includes(search))
|
||
&& !managedCategoryName(category, records).toLocaleLowerCase("it").includes(search)) {
|
||
rendered.hidden = true;
|
||
}
|
||
container.appendChild(rendered);
|
||
});
|
||
applyManagedVisualOrder(container);
|
||
bindLayerManagerDrag(container);
|
||
bindLayerManagerMoveButtons(container);
|
||
if (scrollHost) {
|
||
scrollHost.scrollTop = scrollTop;
|
||
window.requestAnimationFrame(() => {
|
||
scrollHost.scrollTop = scrollTop;
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderLayerManagerCategory(category, records) {
|
||
const group = document.createElement("details");
|
||
group.className = "layer-manager-group";
|
||
group.dataset.dragType = "category";
|
||
group.dataset.layerDepth = "0";
|
||
group.dataset.categoryKey = category.key;
|
||
applyLayerManagerPalette(group, `category:${category.key}`);
|
||
bindLayerManagerExpansion(group, `category:${category.key}`);
|
||
const categoryName = managedCategoryName(category, records);
|
||
const renameButton = managedCategoryCanRename(category)
|
||
? `<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 rowActions = layerManagerRowActions(renameButton, removeButton);
|
||
group.classList.toggle("layer-manager-with-actions", Boolean(rowActions));
|
||
const visibleCount = records.filter(record => !lmeState.managerHiddenObjectIds.has(record.id)).length;
|
||
const categoryVisible = !lmeState.managerHiddenCategories.has(category.key)
|
||
&& (!category.roots.length || category.roots.some(layer => lmeState.map.hasLayer(layer)));
|
||
const visible = categoryVisible;
|
||
group.innerHTML = `<summary><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>${rowActions}${layerManagerMoveButtons("gruppo")}<span class="layer-manager-count">${records.length}</span></summary><div class="layer-manager-children"></div>`;
|
||
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-rename-button")?.addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
renameManagedCategory(category, records);
|
||
});
|
||
group.querySelector(".layer-remove-button")?.addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
removeManagedRecords(records, `il gruppo '${categoryName}'`);
|
||
});
|
||
bindManagedParentHighlight(group, `category:${category.key}`, records);
|
||
const children = group.querySelector(".layer-manager-children");
|
||
if (records.length) children.appendChild(renderManagedGroupTools(records));
|
||
if (category.blocks) {
|
||
const blocks = new Map();
|
||
records.forEach(record => {
|
||
const list = blocks.get(record.source) || [];
|
||
list.push(record);
|
||
blocks.set(record.source, list);
|
||
});
|
||
blocks.forEach((blockRecords, source) => children.appendChild(renderLayerManagerBlock(category, source, blockRecords)));
|
||
} else {
|
||
records.forEach((record, index) => children.appendChild(renderLayerManagerObject(record, index)));
|
||
}
|
||
return group;
|
||
}
|
||
|
||
function renderLayerManagerBlock(category, source, records) {
|
||
const block = document.createElement("details");
|
||
block.className = "layer-manager-block";
|
||
block.dataset.dragType = "block";
|
||
block.dataset.layerDepth = "1";
|
||
block.dataset.categoryKey = category.key;
|
||
block.dataset.blockKey = source;
|
||
applyLayerManagerPalette(block, `block:${category.key}:${source}`);
|
||
bindLayerManagerExpansion(block, `block:${category.key}:${source}`);
|
||
const visibleCount = records.filter(record => !lmeState.managerHiddenObjectIds.has(record.id)).length;
|
||
const visible = visibleCount === records.length;
|
||
const blockName = managedBlockName(source, records);
|
||
const rowActions = layerManagerRowActions(
|
||
`<button type="button" class="layer-rename-button" aria-label="Rinomina gruppo" title="Rinomina">Rinomina</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");
|
||
block.innerHTML = `<summary><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>${rowActions}${layerManagerMoveButtons("blocco")}<span class="layer-manager-count">${records.length}</span></summary><div class="layer-manager-children"></div>`;
|
||
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-rename-button").addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
renameManagedBlock(category, source, records);
|
||
});
|
||
block.querySelector(".layer-remove-button").addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
removeManagedRecords(records, `il gruppo '${blockName}'`);
|
||
});
|
||
bindManagedParentHighlight(block, `block:${category.key}:${source}`, records);
|
||
const children = block.querySelector(".layer-manager-children");
|
||
children.appendChild(renderManagedGroupTools(records));
|
||
records.forEach((record, index) => children.appendChild(renderLayerManagerObject(record, index)));
|
||
return block;
|
||
}
|
||
|
||
function renderLayerManagerObject(record, index) {
|
||
const object = document.createElement("details");
|
||
object.className = "layer-manager-object";
|
||
if (record.id === lmeState.managerHighlightedObjectId) {
|
||
object.classList.add("layer-manager-object-selected");
|
||
}
|
||
object.dataset.dragType = "object";
|
||
object.dataset.layerDepth = "2";
|
||
object.dataset.objectId = record.id;
|
||
bindLayerManagerExpansion(object, `object:${record.id}`);
|
||
const visible = !lmeState.managerHiddenObjectIds.has(record.id);
|
||
const renameButton = managedObjectCanRename(record)
|
||
? `<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 rowActions = layerManagerRowActions(renameButton, removeButton);
|
||
object.classList.toggle("layer-manager-with-actions", Boolean(rowActions));
|
||
const objectName = managedObjectLabel(record, index);
|
||
object.innerHTML = `<summary><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>${rowActions}${layerManagerMoveButtons("oggetto")}<span class="layer-manager-count">›</span></summary><div class="layer-manager-details"></div>`;
|
||
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-manager-title").addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
object.open = true;
|
||
highlightManagedObject(record);
|
||
});
|
||
const details = object.querySelector(".layer-manager-details");
|
||
details.appendChild(renderLayerManagerVisibilityOption(
|
||
geometryVisibilityLabel(record.layer),
|
||
visible,
|
||
checked => setManagedObjectVisible(record, checked, true)));
|
||
if (supportsManagedMeasurements(record)) {
|
||
managedMeasurementKinds(record).forEach(kind => {
|
||
const measurementsVisible = managedMeasurementsGloballyVisible(record.categoryKey)
|
||
&& !managedMeasurementHiddenSet(kind.key).has(record.id);
|
||
details.appendChild(renderLayerManagerVisibilityOption(
|
||
kind.label,
|
||
measurementsVisible,
|
||
checked => setManagedMeasurementKindVisible(record, kind.key, checked)));
|
||
});
|
||
}
|
||
const rows = managedObjectDetails(record.layer);
|
||
const info = document.createElement("div");
|
||
info.className = "layer-manager-info";
|
||
info.innerHTML = rows.length
|
||
? rows.map(value => `<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</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="duplicate">Duplica</button><button type="button" data-action="numeric">Coordinate</button><button type="button" data-action="move">Sposta</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='duplicate']").addEventListener("click", () => duplicateManagedRecords([record]));
|
||
tools.querySelector("[data-action='numeric']").addEventListener("click", () => numericEditManagedRecord(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 updateManagedProperties(records, change) {
|
||
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 (records.some(record => record.layer?.feature?.properties?._lmeLocked)) { setInfo("Sblocca gli oggetti da duplicare."); return; }
|
||
rememberMapChange();
|
||
records.forEach((record, index) => {
|
||
const feature = record.layer.toGeoJSON();
|
||
feature.properties = { ...(feature.properties || {}), name: `${managedObjectLabel(record, index)} - copia`, _lmeManagerId: undefined };
|
||
if (record.layer instanceof L.Circle) {
|
||
const point = turf.transformTranslate(feature, 1.5, 45, { units: "meters" });
|
||
point.properties._lmeShape = "circle";
|
||
point.properties._lmeRadius = record.layer.getRadius();
|
||
addDrawingFeature(point);
|
||
} else {
|
||
addDrawingFeature(turf.transformTranslate(feature, 1.5, 45, { units: "meters" }));
|
||
}
|
||
});
|
||
refreshDrawingMeasurements(); refreshLayerManager(); finishMapChange();
|
||
setInfo(`${records.length} ${records.length === 1 ? "oggetto duplicato" : "oggetti duplicati"}.`);
|
||
}
|
||
|
||
function moveManagedRecords(records) {
|
||
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) { setInfo("Sblocca gli oggetti da spostare."); return; }
|
||
const north = Number(window.prompt("Spostamento verso nord in metri (negativo verso sud)", "0"));
|
||
if (!Number.isFinite(north)) return;
|
||
const east = Number(window.prompt("Spostamento verso est in metri (negativo verso ovest)", "0"));
|
||
if (!Number.isFinite(east) || (!north && !east)) return;
|
||
const distance = Math.hypot(north, east);
|
||
const bearing = Math.atan2(east, north) * 180 / Math.PI;
|
||
rememberMapChange();
|
||
records.forEach(record => {
|
||
const moved = turf.transformTranslate(record.layer.toGeoJSON(), distance, bearing, { units: "meters" });
|
||
replaceManagedLayer(record, moved);
|
||
});
|
||
finishManagedGeometryChange(`Spostati ${records.length} oggetti.`);
|
||
}
|
||
|
||
function bufferManagedRecords(records) {
|
||
const meters = Number(window.prompt("Distanza buffer in metri", "5"));
|
||
if (!Number.isFinite(meters) || meters <= 0) return;
|
||
rememberMapChange();
|
||
let count = 0;
|
||
records.forEach((record, index) => {
|
||
const buffered = turf.buffer(record.layer.toGeoJSON(), meters, { units: "meters" });
|
||
if (!buffered) return;
|
||
buffered.properties = { ...(record.layer.feature?.properties || {}), name: `Buffer ${meters} m - ${managedObjectLabel(record, index)}`, _lmeShape: "polygon", _lmeManagerId: undefined };
|
||
addDrawingFeature(buffered); count++;
|
||
});
|
||
finishManagedGeometryChange(`Creati ${count} buffer da ${formatDistance(meters)}.`);
|
||
}
|
||
|
||
function unionManagedRecords(records) {
|
||
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) {
|
||
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 numericEditManagedRecord(record) {
|
||
if (record.layer?.feature?.properties?._lmeLocked) { setInfo("Sblocca prima l'oggetto."); return; }
|
||
if (record.layer instanceof L.Circle) {
|
||
const center = record.layer.getLatLng();
|
||
const value = window.prompt("Latitudine, longitudine, raggio in metri", `${center.lat.toFixed(7)}, ${center.lng.toFixed(7)}, ${record.layer.getRadius().toFixed(2)}`);
|
||
if (value === null) return;
|
||
const parts = value.split(/[;, ]+/).map(Number).filter(Number.isFinite);
|
||
if (parts.length < 3) { setInfo("Inserisci latitudine, longitudine e raggio validi."); return; }
|
||
rememberMapChange(); record.layer.setLatLng([parts[0], parts[1]]); record.layer.setRadius(parts[2]); annotateDrawingLayer(record.layer, "circle");
|
||
} else if (typeof record.layer?.getLatLng === "function") {
|
||
const point = record.layer.getLatLng();
|
||
const value = window.prompt("Latitudine, longitudine", `${point.lat.toFixed(7)}, ${point.lng.toFixed(7)}`);
|
||
if (value === null) return;
|
||
const parts = value.split(/[;, ]+/).map(Number).filter(Number.isFinite);
|
||
if (parts.length < 2) { setInfo("Inserisci coordinate valide."); return; }
|
||
rememberMapChange(); record.layer.setLatLng([parts[0], parts[1]]);
|
||
} else {
|
||
const lines = lineLatLngGroups(record.layer.getLatLngs());
|
||
const current = lines[0]?.map(point => `${point.lat.toFixed(7)},${point.lng.toFixed(7)}`).join("; ") || "";
|
||
const value = window.prompt("Vertici: coppie latitudine,longitudine separate da punto e virgola", current);
|
||
if (value === null) return;
|
||
const points = value.split(";").map(pair => pair.split(",").map(Number)).filter(pair => pair.length === 2 && pair.every(Number.isFinite));
|
||
if (points.length < 2) { setInfo("Servono almeno due vertici validi."); return; }
|
||
rememberMapChange(); record.layer.setLatLngs(points.map(point => L.latLng(point[0], point[1])));
|
||
}
|
||
finishManagedGeometryChange("Coordinate aggiornate.");
|
||
}
|
||
|
||
function replaceManagedLayer(record, feature) {
|
||
const parent = record.parent?.hasLayer?.(record.layer) ? record.parent : lmeState.drawings;
|
||
parent.removeLayer?.(record.layer);
|
||
clearManagedObjectState(record.id);
|
||
feature.properties = { ...(record.layer.feature?.properties || {}), ...(feature.properties || {}), _lmeManagerId: undefined };
|
||
addDrawingFeature(feature);
|
||
}
|
||
|
||
function removeRecordWithoutPrompt(record) {
|
||
record.parent?.removeLayer?.(record.layer); clearManagedObjectState(record.id);
|
||
}
|
||
|
||
function finishManagedGeometryChange(message) {
|
||
refreshImportedMeasurements(); refreshDrawingMeasurements(); refreshPermanentLabels(); updateGpsInsideState(); refreshLayerManager(); finishMapChange(); setInfo(message);
|
||
}
|
||
|
||
function rememberMapChange() {
|
||
if (lmeState.historyBusy || !lmeState.map) return;
|
||
const snapshot = createProjectSnapshot(true);
|
||
if (lmeState.undoStack.at(-1) !== snapshot) lmeState.undoStack.push(snapshot);
|
||
if (lmeState.undoStack.length > 30) lmeState.undoStack.shift();
|
||
lmeState.redoStack = [];
|
||
}
|
||
|
||
function finishMapChange() {
|
||
if (!lmeState.autoSaveEnabled || lmeState.historyBusy || !lmeState.map) return;
|
||
try { localStorage.setItem("lmeAutosaveV3", createProjectSnapshot(true)); } catch { }
|
||
}
|
||
|
||
function undoMapChange() {
|
||
if (!lmeState.undoStack.length) { setInfo("Nessuna operazione da annullare."); return; }
|
||
lmeState.redoStack.push(createProjectSnapshot(true));
|
||
restoreHistorySnapshot(lmeState.undoStack.pop(), "Operazione annullata.");
|
||
}
|
||
|
||
function redoMapChange() {
|
||
if (!lmeState.redoStack.length) { setInfo("Nessuna operazione da ripristinare."); return; }
|
||
lmeState.undoStack.push(createProjectSnapshot(true));
|
||
restoreHistorySnapshot(lmeState.redoStack.pop(), "Operazione ripristinata.");
|
||
}
|
||
|
||
function restoreHistorySnapshot(snapshot, message) {
|
||
lmeState.historyBusy = true;
|
||
try { loadProjectSnapshot(snapshot, "cronologia"); setInfo(message); } finally { lmeState.historyBusy = false; }
|
||
}
|
||
|
||
function restoreAutosave() {
|
||
const snapshot = localStorage.getItem("lmeAutosaveV3");
|
||
if (!snapshot) { setInfo("Nessun recupero automatico disponibile."); return false; }
|
||
rememberMapChange(); loadProjectSnapshot(snapshot, "recupero automatico"); return true;
|
||
}
|
||
|
||
function setAdvancedOptions(options) {
|
||
const value = options || {};
|
||
lmeState.snapEnabled = value.snapEnabled !== false;
|
||
lmeState.snapDistancePixels = Math.max(4, Math.min(40, Number(value.snapDistancePixels) || 14));
|
||
lmeState.autoSaveEnabled = value.autoSaveEnabled !== false;
|
||
lmeState.gpsProximityEnabled = value.gpsProximityEnabled !== false;
|
||
lmeState.gpsBoundaryAlertEnabled = Boolean(value.gpsBoundaryAlertEnabled);
|
||
}
|
||
|
||
function compareProjectSnapshots(currentRaw, previousRaw) {
|
||
const current = typeof currentRaw === "string" ? JSON.parse(currentRaw) : currentRaw;
|
||
const previous = typeof previousRaw === "string" ? JSON.parse(previousRaw) : previousRaw;
|
||
const collect = snapshot => ["drawings", "imported", "importedSheets", "selectedParcels"]
|
||
.flatMap(key => snapshot?.[key]?.features || []);
|
||
const keyFor = (feature, index) => feature?.properties?._lmeManagerId
|
||
|| `${feature?.properties?.name || feature?.properties?.Name || "object"}:${index}`;
|
||
const currentMap = new Map(collect(current).map((feature, index) => [keyFor(feature, index), JSON.stringify(feature)]));
|
||
const previousMap = new Map(collect(previous).map((feature, index) => [keyFor(feature, index), JSON.stringify(feature)]));
|
||
let added = 0; let removed = 0; let changed = 0;
|
||
currentMap.forEach((value, key) => { if (!previousMap.has(key)) added++; else if (previousMap.get(key) !== value) changed++; });
|
||
previousMap.forEach((_value, key) => { if (!currentMap.has(key)) removed++; });
|
||
const message = `Confronto revisione: ${added} aggiunti, ${removed} rimossi, ${changed} modificati.`;
|
||
setInfo(message);
|
||
return message;
|
||
}
|
||
|
||
function renderLayerManagerVisibilityOption(label, checked, onChange) {
|
||
const option = document.createElement("label");
|
||
option.className = "layer-manager-suboption";
|
||
option.innerHTML = `<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);
|
||
}
|
||
});
|
||
}
|
||
|
||
function setManagedCategoryVisible(category, visible) {
|
||
if (category.key === "imported") {
|
||
lmeState.importedVisible = visible;
|
||
} else if (category.key === "importedSheets") {
|
||
lmeState.importedSheetsVisible = visible;
|
||
} else if (category.key === "selectedParcels") {
|
||
lmeState.selectedParcelsVisible = visible;
|
||
} else if (category.key === "gpsPosition") {
|
||
lmeState.gpsPositionVisible = visible;
|
||
}
|
||
if (visible) {
|
||
lmeState.managerHiddenCategories.delete(category.key);
|
||
category.roots.forEach(layer => {
|
||
const objectId = managedLayerId(layer, category.key);
|
||
if (layer
|
||
&& !lmeState.managerHiddenObjectIds.has(objectId)
|
||
&& !lmeState.map.hasLayer(layer)) {
|
||
layer.addTo(lmeState.map);
|
||
}
|
||
});
|
||
} else {
|
||
lmeState.managerHiddenCategories.add(category.key);
|
||
category.roots.forEach(layer => {
|
||
if (layer && lmeState.map.hasLayer(layer)) {
|
||
lmeState.map.removeLayer(layer);
|
||
}
|
||
});
|
||
if (lmeState.managerHighlightedCategoryKey === category.key) {
|
||
clearManagerHighlight();
|
||
}
|
||
}
|
||
saveLayerManagerState();
|
||
refreshImportedMeasurements();
|
||
refreshDrawingMeasurements();
|
||
refreshLayerManager();
|
||
}
|
||
|
||
function setManagedObjectVisible(record, visible, refresh = true) {
|
||
if (visible) {
|
||
const hidden = lmeState.managerHiddenObjects.get(record.id);
|
||
if (hidden) {
|
||
if (hidden.parent !== lmeState.map || managedCategoryEnabled(record.categoryKey)) {
|
||
hidden.parent.addLayer(hidden.layer);
|
||
}
|
||
lmeState.managerHiddenObjects.delete(record.id);
|
||
}
|
||
lmeState.managerHiddenObjectIds.delete(record.id);
|
||
} else if (!lmeState.managerHiddenObjects.has(record.id)) {
|
||
lmeState.managerHiddenObjects.set(record.id, record);
|
||
lmeState.managerHiddenObjectIds.add(record.id);
|
||
record.parent.removeLayer(record.layer);
|
||
if (lmeState.managerHighlightedObjectId === record.id) {
|
||
clearManagerHighlight();
|
||
}
|
||
}
|
||
saveLayerManagerState();
|
||
refreshImportedMeasurements();
|
||
refreshDrawingMeasurements();
|
||
refreshPermanentLabels();
|
||
if (refresh) {
|
||
refreshLayerManager();
|
||
}
|
||
}
|
||
|
||
function managedCategoryEnabled(categoryKey) {
|
||
if (lmeState.managerHiddenCategories.has(categoryKey)) {
|
||
return false;
|
||
}
|
||
if (categoryKey === "gpsPosition") {
|
||
return lmeState.gpsPositionVisible;
|
||
}
|
||
if (categoryKey === "imported") {
|
||
return lmeState.importedVisible;
|
||
}
|
||
if (categoryKey === "importedSheets") {
|
||
return lmeState.importedSheetsVisible;
|
||
}
|
||
if (categoryKey === "selectedParcels") {
|
||
return lmeState.selectedParcelsVisible;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function highlightManagedObject(record) {
|
||
if (lmeState.managerHighlightedObjectId === record.id) {
|
||
clearManagerHighlight();
|
||
return;
|
||
}
|
||
clearManagerHighlight();
|
||
lmeState.managerHighlightedObjectId = record.id;
|
||
lmeState.managerHighlightedCategoryKey = record.categoryKey;
|
||
document.querySelectorAll(".layer-manager-object").forEach(row => {
|
||
row.classList.toggle("layer-manager-object-selected", row.dataset.objectId === record.id);
|
||
});
|
||
setSelectedDrawing(record.categoryKey === "drawings" ? record.layer : null);
|
||
renderManagerHighlight([record]);
|
||
fitLayer(record.layer);
|
||
}
|
||
|
||
function drawingEditToolbar() {
|
||
return lmeState.drawControl?._toolbars?.[L.EditToolbar.TYPE] || null;
|
||
}
|
||
|
||
function drawingEditActive() {
|
||
return Boolean(drawingEditToolbar()?.enabled?.());
|
||
}
|
||
|
||
function updateDrawingSelectionActions() {
|
||
const hasSelection = Boolean(lmeState.drawingSelection?.getLayers?.().length);
|
||
const hasDrawings = Boolean(lmeState.drawings?.getLayers?.().length);
|
||
lmeState.drawingDeleteButton?.classList.toggle("leaflet-disabled", !hasDrawings);
|
||
if (lmeState.drawingDeleteButton) {
|
||
lmeState.drawingDeleteButton.setAttribute("aria-disabled", String(!hasDrawings));
|
||
}
|
||
lmeState.drawingScopeMenu?.querySelector("[data-drawing-scope='selected']")
|
||
?.setAttribute("aria-disabled", String(!hasSelection));
|
||
}
|
||
|
||
function setSelectedDrawing(layer) {
|
||
if (!lmeState.drawingSelection || drawingEditActive()) {
|
||
return;
|
||
}
|
||
lmeState.drawingSelection.clearLayers();
|
||
if (layer) {
|
||
lmeState.drawingSelection.addLayer(layer);
|
||
}
|
||
updateDrawingSelectionActions();
|
||
}
|
||
|
||
function selectDrawingLayer(layer) {
|
||
if (drawingEditActive()) {
|
||
return;
|
||
}
|
||
const record = {
|
||
id: managedLayerId(layer, "drawings"),
|
||
categoryKey: "drawings",
|
||
layer,
|
||
parent: lmeState.drawings,
|
||
source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente")
|
||
};
|
||
clearManagerHighlight();
|
||
lmeState.managerHighlightedObjectId = record.id;
|
||
lmeState.managerHighlightedCategoryKey = "drawings";
|
||
document.querySelectorAll(".layer-manager-object").forEach(row => {
|
||
row.classList.toggle("layer-manager-object-selected", row.dataset.objectId === record.id);
|
||
});
|
||
setSelectedDrawing(layer);
|
||
renderManagerHighlight([record]);
|
||
setInfo(`Selezione: ${managedObjectLabel(record, 0)}. Usa la matita per modificare o il cestino per eliminare.`);
|
||
}
|
||
|
||
function bindManagedParentHighlight(row, key, records) {
|
||
const summary = row.querySelector(":scope > summary");
|
||
row.dataset.highlightKey = key;
|
||
row.classList.toggle("layer-manager-parent-selected", lmeState.managerHighlightedParentKey === key);
|
||
if (lmeState.managerHighlightedParentKey === key) {
|
||
lmeState.managerHighlightedParentRecords = records;
|
||
}
|
||
row.querySelector(":scope > summary > .layer-manager-title").addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
row.open = true;
|
||
highlightManagedParent(row, key, records);
|
||
});
|
||
summary.addEventListener("pointerenter", () => {
|
||
lmeState.managerHoveredParentKey = key;
|
||
lmeState.managerHoveredParentRecords = records;
|
||
row.classList.add("layer-manager-parent-highlighted");
|
||
renderManagerHighlight(records);
|
||
});
|
||
summary.addEventListener("pointerleave", () => {
|
||
if (lmeState.managerHoveredParentKey !== key) {
|
||
return;
|
||
}
|
||
lmeState.managerHoveredParentKey = null;
|
||
lmeState.managerHoveredParentRecords = null;
|
||
row.classList.remove("layer-manager-parent-highlighted");
|
||
renderSelectedManagerHighlight();
|
||
});
|
||
}
|
||
|
||
function highlightManagedParent(row, key, records) {
|
||
if (lmeState.managerHighlightedParentKey === key) {
|
||
clearManagerHighlight();
|
||
return;
|
||
}
|
||
clearManagerHighlight();
|
||
lmeState.managerHighlightedParentKey = key;
|
||
lmeState.managerHighlightedParentRecords = records;
|
||
lmeState.managerHighlightedCategoryKey = records[0]?.categoryKey || null;
|
||
setSelectedDrawing(null);
|
||
row.classList.add("layer-manager-parent-selected");
|
||
renderManagerHighlight(records);
|
||
}
|
||
|
||
function renderSelectedManagerHighlight() {
|
||
if (lmeState.managerHighlightedParentRecords?.length) {
|
||
renderManagerHighlight(lmeState.managerHighlightedParentRecords);
|
||
return;
|
||
}
|
||
const selected = lmeState.managerRecords.get(lmeState.managerHighlightedObjectId);
|
||
renderManagerHighlight(selected ? [selected] : []);
|
||
}
|
||
|
||
function renderManagerHighlight(records) {
|
||
lmeState.managerHighlight?.clearLayers();
|
||
const features = records.flatMap(record => {
|
||
const geojson = record.layer?.toGeoJSON?.();
|
||
if (!geojson) {
|
||
return [];
|
||
}
|
||
return geojson.type === "FeatureCollection" ? geojson.features : [geojson];
|
||
});
|
||
if (!features.length) {
|
||
return;
|
||
}
|
||
L.geoJSON({ type: "FeatureCollection", features }, {
|
||
pane: "managerHighlightPane",
|
||
interactive: false,
|
||
style: { color: "#ff00d4", weight: 8, opacity: 1, fillColor: "#00fff0", fillOpacity: 0.16 },
|
||
pointToLayer: (feature, latlng) => L.circleMarker(latlng, {
|
||
pane: "managerHighlightPane",
|
||
radius: 12,
|
||
color: "#ff00d4",
|
||
weight: 5,
|
||
fillColor: "#00fff0",
|
||
fillOpacity: 0.9,
|
||
interactive: false
|
||
})
|
||
}).addTo(lmeState.managerHighlight);
|
||
}
|
||
|
||
function clearManagerHighlight() {
|
||
lmeState.managerHighlight?.clearLayers();
|
||
lmeState.managerHighlightedObjectId = null;
|
||
lmeState.managerHighlightedCategoryKey = null;
|
||
lmeState.managerHighlightedParentKey = null;
|
||
lmeState.managerHighlightedParentRecords = null;
|
||
lmeState.managerHoveredParentKey = null;
|
||
lmeState.managerHoveredParentRecords = null;
|
||
setSelectedDrawing(null);
|
||
document.querySelectorAll(".layer-manager-object-selected").forEach(row => {
|
||
row.classList.remove("layer-manager-object-selected");
|
||
});
|
||
document.querySelectorAll(".layer-manager-parent-highlighted").forEach(row => {
|
||
row.classList.remove("layer-manager-parent-highlighted");
|
||
});
|
||
document.querySelectorAll(".layer-manager-parent-selected").forEach(row => {
|
||
row.classList.remove("layer-manager-parent-selected");
|
||
});
|
||
}
|
||
|
||
function layerManagerMoveButtons(label) {
|
||
return `<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); }
|
||
}).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); }
|
||
}).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 => {
|
||
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);
|
||
}
|
||
selectDrawingLayer(layer);
|
||
});
|
||
}
|
||
|
||
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 = [];
|
||
layerManagerCategories().forEach(category => managedRecordsForCategory(category).forEach(record => {
|
||
if (record.layer === layer) return;
|
||
lineLatLngGroups(record.layer?.getLatLngs?.() || []).forEach(line => line.forEach(point => candidates.push(point)));
|
||
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; } });
|
||
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: 4,
|
||
view: {
|
||
latitude: center.lat,
|
||
longitude: center.lng,
|
||
zoom: lmeState.map.getZoom()
|
||
},
|
||
drawings: managedGroupGeoJson(lmeState.drawings, "drawings"),
|
||
imported: managedGroupGeoJson(lmeState.imported, "imported"),
|
||
importedSheets: managedGroupGeoJson(lmeState.importedSheets, "importedSheets"),
|
||
importedSheetsVisible: lmeState.importedSheetsVisible,
|
||
displayOptions: captureDisplayOptions(),
|
||
selectedParcels: includeCadastralParcels
|
||
? managedGroupGeoJson(lmeState.selectedParcelsLayer, "selectedParcels")
|
||
: { type: "FeatureCollection", features: [] },
|
||
trail: lmeState.gpsTrailPoints.map(point => [
|
||
Number(point.lat ?? point[0]),
|
||
Number(point.lng ?? point[1])
|
||
])
|
||
});
|
||
}
|
||
|
||
function getLegacyProjectSnapshot() {
|
||
return localStorage.getItem("lmeMapWorkspace");
|
||
}
|
||
|
||
function clearLegacyProjectSnapshot() {
|
||
localStorage.removeItem("lmeMapWorkspace");
|
||
}
|
||
|
||
function loadProjectSnapshot(raw, projectName) {
|
||
try {
|
||
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||
if (!data || typeof data !== "object") {
|
||
throw new Error("contenuto non valido");
|
||
}
|
||
|
||
resetLayerManagerProjectState();
|
||
lmeState.drawings.clearLayers();
|
||
lmeState.imported.clearLayers();
|
||
lmeState.importedSheets.clearLayers();
|
||
lmeState.drawingMeasurements.clearLayers();
|
||
lmeState.importedMeasurements.clearLayers();
|
||
lmeState.selectedParcelsLayer.clearLayers();
|
||
lmeState.gpsParcelLayer.clearLayers();
|
||
|
||
const restoredDrawings = L.geoJSON(data.drawings || emptyFeatureCollection(), {
|
||
pane: "drawingsPane",
|
||
style: 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.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();
|
||
return captureDisplayOptions();
|
||
} catch (error) {
|
||
setInfo(`Progetto non leggibile: ${error.message}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function captureDisplayOptions() {
|
||
return {
|
||
baseLayer: lmeState.currentBaseName,
|
||
cadastreOpacity: lmeState.cadastreOpacity,
|
||
cadastreLayers: [...lmeState.cadastreLayers],
|
||
includeKmlSheetLayer: lmeState.includeKmlSheetLayer,
|
||
importedVisible: lmeState.importedVisible,
|
||
importedSheetsVisible: lmeState.importedSheetsVisible,
|
||
showImportedMeasurements: lmeState.showImportedMeasurements,
|
||
showDrawingMeasurements: lmeState.showDrawingMeasurements,
|
||
drawToolsVisible: Boolean(lmeState.drawControl),
|
||
gpsPositionVisible: lmeState.gpsPositionVisible,
|
||
selectedParcelsVisible: lmeState.selectedParcelsVisible,
|
||
layerOrder: [...lmeState.layerOrder],
|
||
layerObjectOrder: { ...lmeState.managerObjectOrder },
|
||
layerCategoryNames: { ...lmeState.managerCategoryNames },
|
||
removedLayerCategories: [...lmeState.managerRemovedCategories],
|
||
hiddenLayerObjects: [...lmeState.managerHiddenObjectIds],
|
||
hiddenLayerMeasurementSummaries: [...lmeState.managerHiddenMeasurementSummaryIds],
|
||
hiddenLayerMeasurementSides: [...lmeState.managerHiddenMeasurementSideIds],
|
||
hiddenLayerCategories: [...lmeState.managerHiddenCategories],
|
||
trailEnabled: lmeState.gpsTrailEnabled
|
||
};
|
||
}
|
||
|
||
function applyProjectDisplayOptions(options) {
|
||
const supportedCadastreLayers = new Set([
|
||
"CP.CadastralParcel",
|
||
"codice_plla",
|
||
"CP.CadastralZoning",
|
||
"fabbricati",
|
||
"strade",
|
||
"acque"
|
||
]);
|
||
if (Array.isArray(options.cadastreLayers)) {
|
||
lmeState.cadastreLayers = options.cadastreLayers.filter(layer => supportedCadastreLayers.has(layer));
|
||
}
|
||
|
||
const opacity = Number(options.cadastreOpacity);
|
||
if (Number.isFinite(opacity)) {
|
||
lmeState.cadastreOpacity = Math.max(0, Math.min(1, opacity));
|
||
}
|
||
lmeState.includeKmlSheetLayer = Boolean(options.includeKmlSheetLayer);
|
||
lmeState.importedVisible = options.importedVisible !== false;
|
||
lmeState.importedSheetsVisible = Boolean(options.importedSheetsVisible);
|
||
lmeState.showImportedMeasurements = Boolean(options.showImportedMeasurements);
|
||
lmeState.showDrawingMeasurements = options.showDrawingMeasurements !== false;
|
||
if (Array.isArray(options.layerOrder)) {
|
||
setLayerOrder(options.layerOrder);
|
||
}
|
||
if (options.layerObjectOrder && typeof options.layerObjectOrder === "object") {
|
||
lmeState.managerObjectOrder = options.layerObjectOrder;
|
||
}
|
||
if (options.layerCategoryNames && typeof options.layerCategoryNames === "object") {
|
||
lmeState.managerCategoryNames = { ...options.layerCategoryNames };
|
||
}
|
||
if (Array.isArray(options.removedLayerCategories)) {
|
||
lmeState.managerRemovedCategories = new Set(options.removedLayerCategories);
|
||
}
|
||
if (Array.isArray(options.hiddenLayerObjects)) {
|
||
lmeState.managerHiddenObjectIds = new Set(options.hiddenLayerObjects);
|
||
}
|
||
if (Array.isArray(options.hiddenLayerMeasurementSummaries)) {
|
||
lmeState.managerHiddenMeasurementSummaryIds = new Set(options.hiddenLayerMeasurementSummaries);
|
||
}
|
||
if (Array.isArray(options.hiddenLayerMeasurementSides)) {
|
||
lmeState.managerHiddenMeasurementSideIds = new Set(options.hiddenLayerMeasurementSides);
|
||
}
|
||
if (Array.isArray(options.hiddenLayerCategories)) {
|
||
lmeState.managerHiddenCategories = new Set(options.hiddenLayerCategories);
|
||
}
|
||
applyImportedSheetVisibility();
|
||
setImportedLayerVisible(lmeState.importedVisible);
|
||
refreshCadastreLayer();
|
||
|
||
if (typeof options.baseLayer === "string") {
|
||
setBaseLayer(options.baseLayer);
|
||
}
|
||
setGpsPositionVisible(options.gpsPositionVisible !== false);
|
||
setSelectedParcelsVisible(options.selectedParcelsVisible !== false);
|
||
toggleTrail(Boolean(options.trailEnabled));
|
||
setDrawToolsVisible(Boolean(options.drawToolsVisible));
|
||
applyStoredLayerManagerVisibility();
|
||
saveLayerManagerState();
|
||
}
|
||
|
||
function partitionStoredImportedFeatures(imported, storedSheets) {
|
||
const parcelFeatures = [];
|
||
const sheetFeatures = [...(storedSheets?.features || [])];
|
||
const sourceGroups = new Map();
|
||
|
||
(imported?.features || []).forEach(feature => {
|
||
if (isSheetOrOverlayFeature(feature)) {
|
||
sheetFeatures.push(feature);
|
||
return;
|
||
}
|
||
const source = String(feature?.properties?._lmeSource || "");
|
||
if (!/\.(kml|kmz)$/i.test(source)) {
|
||
parcelFeatures.push(feature);
|
||
return;
|
||
}
|
||
const group = sourceGroups.get(source) || [];
|
||
group.push(feature);
|
||
sourceGroups.set(source, group);
|
||
});
|
||
|
||
sourceGroups.forEach(features => {
|
||
const polygonParts = splitKmlPolygonFeatures(features);
|
||
const parcels = filterKmlParcelFeatures(polygonParts);
|
||
const parcelSet = new Set(parcels);
|
||
parcelFeatures.push(...parcels);
|
||
sheetFeatures.push(...polygonParts.filter(feature => !parcelSet.has(feature)));
|
||
sheetFeatures.push(...features.filter(feature => geometryPolygons(feature.geometry).length === 0));
|
||
});
|
||
|
||
return {
|
||
parcels: { type: "FeatureCollection", features: parcelFeatures },
|
||
sheets: { type: "FeatureCollection", features: sheetFeatures }
|
||
};
|
||
}
|
||
|
||
function emptyFeatureCollection() {
|
||
return { type: "FeatureCollection", features: [] };
|
||
}
|
||
|
||
function managedGroupGeoJson(group, categoryKey) {
|
||
const collection = group?.toGeoJSON?.() || emptyFeatureCollection();
|
||
const features = [...(collection.features || [])];
|
||
const existingIds = new Set(features.map(feature => feature?.properties?._lmeManagerId).filter(Boolean));
|
||
lmeState.managerHiddenObjects.forEach(record => {
|
||
if (record.categoryKey !== categoryKey) {
|
||
return;
|
||
}
|
||
const hidden = record.layer?.toGeoJSON?.();
|
||
const hiddenFeatures = hidden?.type === "FeatureCollection" ? hidden.features : hidden ? [hidden] : [];
|
||
hiddenFeatures.forEach(feature => {
|
||
const id = feature?.properties?._lmeManagerId;
|
||
if (!id || !existingIds.has(id)) {
|
||
features.push(feature);
|
||
if (id) {
|
||
existingIds.add(id);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
const categoryName = lmeState.managerCategoryNames[categoryKey];
|
||
if (categoryName) {
|
||
features.forEach(feature => {
|
||
feature.properties = { ...(feature.properties || {}), _lmeCategoryName: categoryName };
|
||
});
|
||
}
|
||
return { type: "FeatureCollection", features };
|
||
}
|
||
|
||
function resetLayerManagerProjectState() {
|
||
drawingEditToolbar()?.disable?.();
|
||
lmeState.drawingSelection?.clearLayers();
|
||
lmeState.managerHiddenObjects.clear();
|
||
lmeState.managerHiddenObjectIds.clear();
|
||
lmeState.managerHiddenMeasurementSummaryIds.clear();
|
||
lmeState.managerHiddenMeasurementSideIds.clear();
|
||
lmeState.managerHiddenCategories.clear();
|
||
lmeState.managerRemovedCategories.clear();
|
||
lmeState.managerObjectOrder = {};
|
||
lmeState.managerCategoryNames = {};
|
||
lmeState.managerSelectedObjectIds.clear();
|
||
lmeState.permanentLabels?.clearLayers();
|
||
clearManagerHighlight();
|
||
}
|
||
|
||
function clearManagedCategoryState(categoryKey) {
|
||
const category = layerManagerCategories().find(item => item.key === categoryKey);
|
||
if (category) {
|
||
managedRecordsForCategory(category).forEach(record => {
|
||
lmeState.managerHiddenMeasurementSummaryIds.delete(record.id);
|
||
lmeState.managerHiddenMeasurementSideIds.delete(record.id);
|
||
});
|
||
}
|
||
[...lmeState.managerHiddenObjects.entries()].forEach(([id, record]) => {
|
||
if (record.categoryKey === categoryKey) {
|
||
lmeState.managerHiddenObjects.delete(id);
|
||
lmeState.managerHiddenObjectIds.delete(id);
|
||
}
|
||
});
|
||
lmeState.managerHiddenCategories.delete(categoryKey);
|
||
delete lmeState.managerObjectOrder[categoryKey];
|
||
}
|
||
|
||
function bindStoredFeaturePopup(feature, layer) {
|
||
updateStoredFeaturePopup(layer, feature?.properties);
|
||
}
|
||
|
||
function updateStoredFeaturePopup(layer, sourceProperties) {
|
||
const properties = sourceProperties || layer?.feature?.properties || {};
|
||
const label = properties.name || properties.Name || properties.label || properties.LABEL;
|
||
const notes = String(properties._lmeNotes || "").trim();
|
||
const photo = String(properties._lmePhoto || "");
|
||
if (label || notes || photo) {
|
||
const image = photo.startsWith("data:image/") ? `<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 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: "Offline, solo dati vettoriali",
|
||
"azure-road": "Strade Azure Maps",
|
||
"azure-satellite": "Satellite Azure Maps",
|
||
"google-satellite": "Satellite Google Maps",
|
||
"google-roadmap": "Strade Google Maps"
|
||
};
|
||
setInfo(`Sfondo attivo: ${labels[name] || labels.satellite}. Il catasto AdE resta sovrapposto.`);
|
||
if (lmeState.currentBaseName.startsWith("azure-")) {
|
||
void updateAzureAttribution();
|
||
} else {
|
||
clearAzureAttribution();
|
||
}
|
||
}
|
||
|
||
function configureGoogleMaps(apiKey) {
|
||
const key = (apiKey || "").trim();
|
||
if (!key) {
|
||
return Promise.reject(new Error("Chiave Google Maps mancante."));
|
||
}
|
||
|
||
if (window.google?.maps && L.gridLayer.googleMutant) {
|
||
createGoogleLayers();
|
||
return Promise.resolve();
|
||
}
|
||
|
||
if (lmeState.googleMapsLoading) {
|
||
return lmeState.googleMapsLoading;
|
||
}
|
||
|
||
lmeState.googleMapsLoading = new Promise((resolve, reject) => {
|
||
const callbackName = `lmeGoogleMapsReady${Date.now()}`;
|
||
const script = document.createElement("script");
|
||
window[callbackName] = () => {
|
||
delete window[callbackName];
|
||
createGoogleLayers();
|
||
resolve();
|
||
};
|
||
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&loading=async&callback=${callbackName}`;
|
||
script.async = true;
|
||
script.onerror = () => {
|
||
delete window[callbackName];
|
||
lmeState.googleMapsLoading = null;
|
||
reject(new Error("Impossibile caricare Google Maps. Controlla chiave, API e connessione."));
|
||
};
|
||
document.head.appendChild(script);
|
||
});
|
||
|
||
return lmeState.googleMapsLoading;
|
||
}
|
||
|
||
function createGoogleLayers() {
|
||
if (!L.gridLayer.googleMutant) {
|
||
throw new Error("Modulo Google Maps non disponibile.");
|
||
}
|
||
|
||
lmeState.baseLayers["google-satellite"] ||= L.gridLayer.googleMutant({ type: "hybrid", maxZoom: 22 });
|
||
lmeState.baseLayers["google-roadmap"] ||= L.gridLayer.googleMutant({ type: "roadmap", maxZoom: 22 });
|
||
}
|
||
|
||
function configureAzureMaps(subscriptionKey) {
|
||
const key = (subscriptionKey || "").trim();
|
||
if (!key) {
|
||
throw new Error("Chiave Azure Maps mancante.");
|
||
}
|
||
if (lmeState.azureMapsKey === key
|
||
&& lmeState.baseLayers["azure-road"]
|
||
&& lmeState.baseLayers["azure-satellite"]) {
|
||
return;
|
||
}
|
||
|
||
const activeAzureLayer = lmeState.currentBaseName.startsWith("azure-")
|
||
? lmeState.currentBaseName
|
||
: null;
|
||
if (activeAzureLayer && lmeState.currentBase) {
|
||
lmeState.map.removeLayer(lmeState.currentBase);
|
||
}
|
||
|
||
lmeState.azureMapsKey = key;
|
||
lmeState.baseLayers["azure-road"] = createAzureLayer(key, "microsoft.base.road", 22);
|
||
lmeState.baseLayers["azure-satellite"] = createAzureLayer(key, "microsoft.imagery", 19);
|
||
|
||
if (activeAzureLayer) {
|
||
lmeState.currentBase = lmeState.baseLayers[activeAzureLayer].addTo(lmeState.map);
|
||
lmeState.currentBase.bringToBack();
|
||
void updateAzureAttribution();
|
||
}
|
||
}
|
||
|
||
function clearAzureMaps() {
|
||
if (lmeState.currentBaseName.startsWith("azure-") && lmeState.currentBase) {
|
||
lmeState.map.removeLayer(lmeState.currentBase);
|
||
lmeState.currentBase = lmeState.baseLayers.satellite.addTo(lmeState.map);
|
||
lmeState.currentBase.bringToBack();
|
||
lmeState.currentBaseName = "satellite";
|
||
}
|
||
|
||
delete lmeState.baseLayers["azure-road"];
|
||
delete lmeState.baseLayers["azure-satellite"];
|
||
lmeState.azureMapsKey = null;
|
||
clearAzureAttribution();
|
||
}
|
||
|
||
function createAzureLayer(key, tilesetId, maxNativeZoom) {
|
||
const url = `https://atlas.microsoft.com/map/tile?api-version=2024-04-01&tilesetId=${tilesetId}&zoom={z}&x={x}&y={y}&tileSize=256&language=it-IT&view=Auto&subscription-key=${encodeURIComponent(key)}`;
|
||
const layer = L.tileLayer(url, {
|
||
minZoom: tilesetId === "microsoft.imagery" ? 1 : 0,
|
||
maxNativeZoom,
|
||
maxZoom: 22,
|
||
attribution: "Microsoft Azure Maps"
|
||
});
|
||
let errorShown = false;
|
||
layer.on("tileerror", () => {
|
||
if (!errorShown) {
|
||
errorShown = true;
|
||
setInfo("Azure Maps non ha restituito le tessere. Controlla quota e permessi Render della chiave.");
|
||
}
|
||
});
|
||
return layer;
|
||
}
|
||
|
||
async function updateAzureAttribution() {
|
||
if (!lmeState.azureMapsKey || !lmeState.currentBaseName.startsWith("azure-")) {
|
||
return;
|
||
}
|
||
|
||
const requestedLayer = lmeState.currentBaseName;
|
||
const tilesetId = requestedLayer === "azure-satellite" ? "microsoft.imagery" : "microsoft.base.road";
|
||
const bounds = lmeState.map.getBounds();
|
||
const zoom = Math.min(lmeState.map.getZoom(), tilesetId === "microsoft.imagery" ? 19 : 22);
|
||
const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()].join(",");
|
||
const url = `https://atlas.microsoft.com/map/attribution?api-version=2024-04-01&tilesetId=${tilesetId}&zoom=${zoom}&bounds=${bbox}`;
|
||
|
||
try {
|
||
const response = await fetch(url, {
|
||
headers: {
|
||
Accept: "application/json",
|
||
"subscription-key": lmeState.azureMapsKey
|
||
}
|
||
});
|
||
if (!response.ok) {
|
||
return;
|
||
}
|
||
const payload = await response.json();
|
||
if (lmeState.currentBaseName !== requestedLayer) {
|
||
return;
|
||
}
|
||
|
||
const attribution = [...new Set((payload.copyrights || []).map(htmlToText).filter(Boolean))].join(" | ");
|
||
setAzureAttribution(attribution);
|
||
} catch {
|
||
clearAzureAttribution();
|
||
}
|
||
}
|
||
|
||
function setAzureAttribution(text) {
|
||
clearAzureAttribution();
|
||
if (!text) {
|
||
return;
|
||
}
|
||
lmeState.azureAttribution = escapeHtml(text);
|
||
lmeState.map.attributionControl.addAttribution(lmeState.azureAttribution);
|
||
}
|
||
|
||
function clearAzureAttribution() {
|
||
if (lmeState.azureAttribution) {
|
||
lmeState.map.attributionControl.removeAttribution(lmeState.azureAttribution);
|
||
lmeState.azureAttribution = null;
|
||
}
|
||
}
|
||
|
||
function htmlToText(value) {
|
||
const element = document.createElement("div");
|
||
element.innerHTML = String(value || "");
|
||
return element.textContent?.trim() || "";
|
||
}
|
||
|
||
function setCadastreOpacity(value) {
|
||
const opacity = Number(value);
|
||
if (!Number.isFinite(opacity)) {
|
||
return;
|
||
}
|
||
lmeState.cadastreOpacity = Math.max(0, Math.min(1, opacity));
|
||
if (lmeState.cadastre) {
|
||
lmeState.cadastre.setOpacity(lmeState.cadastreOpacity);
|
||
}
|
||
}
|
||
|
||
function toggleCadastreLayer(layer, enabled) {
|
||
const exists = lmeState.cadastreLayers.includes(layer);
|
||
if (enabled && !exists) {
|
||
lmeState.cadastreLayers.push(layer);
|
||
}
|
||
if (!enabled && exists) {
|
||
lmeState.cadastreLayers = lmeState.cadastreLayers.filter(item => item !== layer);
|
||
}
|
||
refreshCadastreLayer();
|
||
const layerNames = {
|
||
"CP.CadastralParcel": "Confini particelle",
|
||
codice_plla: "Numeri particelle",
|
||
"CP.CadastralZoning": "Fogli catastali",
|
||
fabbricati: "Fabbricati",
|
||
strade: "Strade catastali",
|
||
acque: "Acque"
|
||
};
|
||
if (enabled && layer === "codice_plla" && lmeState.map.getZoom() < 17) {
|
||
setInfo("Numeri particelle attivati. Ingrandisci la mappa almeno fino a zoom 17 per renderli visibili.");
|
||
return;
|
||
}
|
||
setInfo(`${layerNames[layer] || layer}: ${enabled ? "attivo" : "nascosto"}.`);
|
||
}
|
||
|
||
function refreshCadastreLayer() {
|
||
if (lmeState.cadastre) {
|
||
lmeState.map.removeLayer(lmeState.cadastre);
|
||
}
|
||
|
||
const layers = activeCadastreLayers();
|
||
lmeState.cadastre = null;
|
||
if (!layers.length) {
|
||
return;
|
||
}
|
||
|
||
const cadastreLayer = L.tileLayer.wms(cadastreUrl(), {
|
||
pane: "cadastrePane",
|
||
layers: layers.join(","),
|
||
format: "image/png",
|
||
transparent: true,
|
||
version: "1.1.1",
|
||
crs: cadastreCrs,
|
||
maxZoom: 22,
|
||
opacity: lmeState.cadastreOpacity,
|
||
attribution: "Agenzia delle Entrate - CC BY 4.0"
|
||
});
|
||
|
||
let errorShown = false;
|
||
cadastreLayer.on("tileerror", () => {
|
||
if (errorShown) {
|
||
return;
|
||
}
|
||
|
||
errorShown = true;
|
||
setInfo("Layer catastali non caricati dal servizio AdE. Controlla la connessione e riprova.");
|
||
});
|
||
cadastreLayer.on("load", () => {
|
||
errorShown = false;
|
||
});
|
||
|
||
lmeState.cadastre = cadastreLayer.addTo(lmeState.map);
|
||
}
|
||
|
||
function activeCadastreLayers() {
|
||
const drawOrder = [
|
||
"CP.CadastralZoning",
|
||
"strade",
|
||
"acque",
|
||
"fabbricati",
|
||
"CP.CadastralParcel",
|
||
"codice_plla"
|
||
];
|
||
return drawOrder.filter(layer => lmeState.cadastreLayers.includes(layer));
|
||
}
|
||
|
||
function clearSelectedParcels() {
|
||
const count = managedGroupGeoJson(lmeState.selectedParcelsLayer, "selectedParcels").features.length;
|
||
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) {
|
||
setInfo("Registrazione percorso disattivata.");
|
||
}
|
||
}
|
||
|
||
function onGpsPosition(position) {
|
||
const { latitude, longitude, accuracy } = position.coords;
|
||
const latlng = [latitude, longitude];
|
||
lmeState.lastPosition = latlng;
|
||
lmeState.lastAccuracy = Math.max(0, Number(accuracy) || 0);
|
||
const firstLiveFix = !lmeState.hasLiveGpsFix;
|
||
lmeState.hasLiveGpsFix = true;
|
||
updateGpsPositionLayers(latlng, lmeState.lastAccuracy);
|
||
if (firstLiveFix) {
|
||
lmeState.map.setView(latlng, 19);
|
||
}
|
||
|
||
if (lmeState.gpsTrailEnabled) {
|
||
lmeState.gpsTrailPoints.push(latlng);
|
||
lmeState.gpsTrail.setLatLngs(lmeState.gpsTrailPoints);
|
||
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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|