6395 lines
279 KiB
JavaScript
6395 lines
279 KiB
JavaScript
const lmeState = {
|
||
map: null,
|
||
baseLayers: {},
|
||
currentBase: null,
|
||
currentBaseName: "satellite",
|
||
layerOrder: [
|
||
"gpsPosition",
|
||
"gpsTrail",
|
||
"fiducials",
|
||
"drawings",
|
||
"gpsParcels",
|
||
"selectedParcels",
|
||
"importedSheets",
|
||
"imported",
|
||
"cadastre"
|
||
],
|
||
cadastre: null,
|
||
cadastreZoning: null,
|
||
cadastreLayers: ["CP.CadastralParcel", "codice_plla", "CP.CadastralZoning", "fabbricati"],
|
||
cadastreOpacity: 0.75,
|
||
navigationSheetLayer: null,
|
||
navigationSheetFeature: null,
|
||
navigationSheetLabel: null,
|
||
navigationSheetRefreshTimer: null,
|
||
navigationSheetRequestId: 0,
|
||
imported: null,
|
||
importedVisible: true,
|
||
importedSheets: null,
|
||
importedSheetsVisible: false,
|
||
importedMeasurements: null,
|
||
drawings: null,
|
||
drawingsRenderer: null,
|
||
drawingSelection: null,
|
||
drawingMeasurements: null,
|
||
drawControl: null,
|
||
drawingDeleteButton: null,
|
||
drawingScopeMenu: null,
|
||
geometryToolsControl: null,
|
||
fiducialDownloadButton: null,
|
||
ignoreMapClickUntil: 0,
|
||
activeDrawHandler: null,
|
||
drawEventsBound: false,
|
||
includeKmlSheetLayer: false,
|
||
showImportedMeasurements: false,
|
||
showDrawingMeasurements: true,
|
||
measurementRefreshTimer: null,
|
||
gpsMarker: null,
|
||
gpsAccuracy: null,
|
||
gpsPositionVisible: true,
|
||
hasLiveGpsFix: false,
|
||
lastAccuracy: 0,
|
||
gpsTrail: null,
|
||
selectedParcelsLayer: null,
|
||
selectedParcelsVisible: true,
|
||
fiducials: null,
|
||
fiducialsVisible: true,
|
||
fiducialColor: "#dc2626",
|
||
gpsParcelLayer: null,
|
||
gpsTrailEnabled: false,
|
||
gpsTrailPoints: [],
|
||
gpsTrailStartedAt: null,
|
||
gpsWatchId: null,
|
||
lastPosition: null,
|
||
infoTimeoutSeconds: 3,
|
||
infoTimer: null,
|
||
fileImportBound: false,
|
||
resizeBound: false,
|
||
googleMapsLoading: null,
|
||
azureMapsKey: null,
|
||
azureAttribution: null,
|
||
managerHighlight: null,
|
||
managerHighlightedObjectId: null,
|
||
managerHighlightedCategoryKey: null,
|
||
managerHighlightedParentKey: null,
|
||
managerHighlightedParentRecords: null,
|
||
managerHoveredParentKey: null,
|
||
managerHoveredParentRecords: null,
|
||
managerOutsideSelectionBound: false,
|
||
managerHiddenObjects: new Map(),
|
||
managerHiddenObjectIds: new Set(),
|
||
managerHiddenMeasurementSummaryIds: new Set(),
|
||
managerHiddenMeasurementSideIds: new Set(),
|
||
managerHiddenCategories: new Set(),
|
||
managerRemovedCategories: new Set(),
|
||
managerObjectOrder: {},
|
||
managerCategoryNames: {},
|
||
managerExpandedRows: new Set(),
|
||
managerSelectedObjectIds: new Set(),
|
||
managerSearchText: "",
|
||
permanentLabels: null,
|
||
undoStack: [],
|
||
redoStack: [],
|
||
historyBusy: false,
|
||
snapEnabled: true,
|
||
snapDistancePixels: 14,
|
||
autoSaveEnabled: true,
|
||
autoFitSelectedObject: false,
|
||
gpsProximityEnabled: true,
|
||
gpsBoundaryAlertEnabled: false,
|
||
lastBoundaryState: null,
|
||
trash: [],
|
||
auditLog: [],
|
||
isDirty: false,
|
||
readOnly: false,
|
||
changeNotifyTimer: null,
|
||
offlineDbPromise: null,
|
||
offlineTileCount: 0,
|
||
dotNetReference: null,
|
||
culture: "it-IT",
|
||
measurementSystem: "metric",
|
||
entitlements: {
|
||
advancedGeometry: false,
|
||
maxDrawingObjects: 15,
|
||
drawingLimitMessage: "Hai raggiunto il limite di 15 oggetti del piano Base."
|
||
},
|
||
labels: {
|
||
measureArea: "Misura area",
|
||
measureDistance: "Misura distanza",
|
||
measureCircle: "Misura cerchio",
|
||
drawRectangle: "Disegna rettangolo",
|
||
addPoi: "Aggiungi POI",
|
||
proRequired: "Funzione inclusa in LME Pro."
|
||
}
|
||
};
|
||
|
||
// AdE publishes the cadastral WMS in ETRS89. WMS 1.1.1 keeps the longitude,
|
||
// latitude BBOX order expected by Leaflet while sending the required CRS code.
|
||
const cadastreCrs = L.extend({}, L.CRS.EPSG4326, { code: "EPSG:4258" });
|
||
|
||
window.lmeMap = {
|
||
init,
|
||
showSearchResult,
|
||
showInitialPosition,
|
||
displayOfficialParcel,
|
||
exportCadastreView,
|
||
closeCadastrePreview,
|
||
clearSelectedParcels,
|
||
setSelectedParcelsVisible,
|
||
setFiducialPoints,
|
||
setFiducialPointsVisible,
|
||
setFiducialColor,
|
||
fitFiducialPoints,
|
||
clearMapElements,
|
||
fitImported,
|
||
clearImported,
|
||
setIncludeKmlSheetLayer,
|
||
setImportedLayerVisible,
|
||
setImportedSheetLayerVisible,
|
||
setLayerOrder,
|
||
refreshLayerManager,
|
||
clearLayerManagerSelection: clearManagerHighlight,
|
||
setImportedMeasurementsVisible,
|
||
setDrawingMeasurementsVisible,
|
||
addProjectMarker,
|
||
startMeasurementDrawing,
|
||
setDrawToolsVisible,
|
||
clearDrawings,
|
||
undoMapChange,
|
||
redoMapChange,
|
||
getHistoryState,
|
||
markProjectSaved,
|
||
hasUnsavedChanges: () => lmeState.isDirty,
|
||
confirmDiscardChanges,
|
||
setProjectReadOnly,
|
||
restoreAutosave,
|
||
compareProjectSnapshots,
|
||
promptProjectName,
|
||
setAdvancedOptions,
|
||
createMapExportGeoJson,
|
||
createProjectSnapshot,
|
||
loadProjectSnapshot,
|
||
getLegacyProjectSnapshot,
|
||
clearLegacyProjectSnapshot,
|
||
setBaseLayer,
|
||
configureGoogleMaps,
|
||
configureAzureMaps,
|
||
clearAzureMaps,
|
||
setCadastreOpacity,
|
||
toggleCadastreLayer,
|
||
startGps,
|
||
stopGps,
|
||
centerOnGps,
|
||
toggleTrail,
|
||
clearGpsTrail,
|
||
getGpsTrailStats,
|
||
setNativeGpsPosition,
|
||
setGpsPositionVisible,
|
||
setGpsError,
|
||
saveGpsMeasurementPoint,
|
||
restoreLastTrashItem,
|
||
emptyTrash,
|
||
showObjectTable,
|
||
showAuditLog,
|
||
showCoordinateInspector,
|
||
validateAndRepairSelected,
|
||
createHoleFromSelected,
|
||
compareSelectedGeometries,
|
||
downloadOfflineArea,
|
||
clearOfflineTiles,
|
||
getOfflineTileStats,
|
||
resize,
|
||
setLocalization,
|
||
setEntitlements,
|
||
importExternalGisFile,
|
||
showInfo: setInfo,
|
||
hideInfo,
|
||
setInfoTimeout
|
||
};
|
||
|
||
function setLocalization(options = {}) {
|
||
lmeState.culture = options.culture || lmeState.culture || "it-IT";
|
||
lmeState.measurementSystem = options.measurementSystem === "imperial" ? "imperial" : "metric";
|
||
lmeState.labels = { ...lmeState.labels, ...(options.labels || {}) };
|
||
configureDrawLabels();
|
||
if (lmeState.drawControl) {
|
||
const wasVisible = true;
|
||
setDrawToolsVisible(false);
|
||
if (wasVisible) {
|
||
setDrawToolsVisible(true);
|
||
}
|
||
}
|
||
refreshImportedMeasurements();
|
||
refreshDrawingMeasurements();
|
||
refreshLayerManager();
|
||
}
|
||
|
||
function setEntitlements(options = {}) {
|
||
lmeState.entitlements = { ...lmeState.entitlements, ...options };
|
||
updateFiducialDownloadButton();
|
||
}
|
||
|
||
function requireProFeature(feature) {
|
||
if (lmeState.entitlements?.[feature]) return true;
|
||
setInfo(lmeState.labels.proRequired || "Funzione inclusa in LME Pro.");
|
||
return false;
|
||
}
|
||
|
||
function drawingObjectCount() {
|
||
return lmeState.drawings ? managedRecordsForCategory("drawings").length : 0;
|
||
}
|
||
|
||
function canAddDrawingObjects(amount = 1) {
|
||
const configuredMaximum = lmeState.entitlements?.maxDrawingObjects;
|
||
if (configuredMaximum === null || configuredMaximum === undefined || configuredMaximum === "") return true;
|
||
const maximum = Number(configuredMaximum);
|
||
if (!Number.isFinite(maximum) || maximum < 0) return true;
|
||
if (drawingObjectCount() + Math.max(0, Number(amount) || 0) <= maximum) return true;
|
||
setInfo(lmeState.entitlements?.drawingLimitMessage || `Limite di ${maximum} oggetti raggiunto.`);
|
||
return false;
|
||
}
|
||
|
||
function init(dotNetReference) {
|
||
if (dotNetReference) {
|
||
lmeState.dotNetReference = dotNetReference;
|
||
}
|
||
if (lmeState.map) {
|
||
return;
|
||
}
|
||
|
||
lmeState.map = L.map("map", {
|
||
zoomControl: false,
|
||
attributionControl: false
|
||
}).setView([41.9028, 12.4964], 17);
|
||
|
||
L.control.zoom({ position: "bottomright" }).addTo(lmeState.map);
|
||
|
||
lmeState.baseLayers.osm = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||
maxNativeZoom: 19,
|
||
maxZoom: 22,
|
||
attribution: "© OpenStreetMap contributors"
|
||
});
|
||
|
||
lmeState.baseLayers.satellite = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", {
|
||
maxNativeZoom: 19,
|
||
maxZoom: 22,
|
||
attribution: "Tiles © Esri"
|
||
});
|
||
|
||
lmeState.baseLayers.topographic = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", {
|
||
maxNativeZoom: 19,
|
||
maxZoom: 22,
|
||
attribution: "Tiles © Esri"
|
||
});
|
||
|
||
lmeState.baseLayers["streets-esri"] = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}", {
|
||
maxNativeZoom: 19,
|
||
maxZoom: 22,
|
||
attribution: "Tiles © Esri"
|
||
});
|
||
|
||
lmeState.baseLayers.terrain = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", {
|
||
maxNativeZoom: 17,
|
||
maxZoom: 22,
|
||
attribution: "Map data © OpenStreetMap contributors, SRTM | Map style © OpenTopoMap"
|
||
});
|
||
|
||
lmeState.baseLayers.carto = L.tileLayer("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", {
|
||
subdomains: "abcd",
|
||
maxNativeZoom: 20,
|
||
maxZoom: 22,
|
||
attribution: "© OpenStreetMap contributors © CARTO"
|
||
});
|
||
lmeState.baseLayers.offline = createOfflineTileLayer();
|
||
|
||
lmeState.currentBase = lmeState.baseLayers.satellite.addTo(lmeState.map);
|
||
restoreLayerOrder();
|
||
Object.values(managedLayerPanes()).forEach(paneName => {
|
||
if (!lmeState.map.getPane(paneName)) {
|
||
lmeState.map.createPane(paneName);
|
||
}
|
||
});
|
||
const managerHighlightPane = lmeState.map.createPane("managerHighlightPane");
|
||
managerHighlightPane.style.zIndex = "650";
|
||
lmeState.drawingsRenderer = L.canvas({ pane: "drawingsPane", tolerance: 14 });
|
||
setLayerOrder(lmeState.layerOrder);
|
||
lmeState.imported = L.featureGroup().addTo(lmeState.map);
|
||
lmeState.importedSheets = L.featureGroup();
|
||
lmeState.drawings = L.featureGroup().addTo(lmeState.map);
|
||
lmeState.drawingSelection = L.featureGroup();
|
||
lmeState.importedMeasurements = L.layerGroup().addTo(lmeState.map);
|
||
lmeState.drawingMeasurements = L.layerGroup().addTo(lmeState.map);
|
||
lmeState.selectedParcelsLayer = L.featureGroup().addTo(lmeState.map);
|
||
lmeState.fiducials = L.featureGroup().addTo(lmeState.map);
|
||
lmeState.gpsParcelLayer = L.featureGroup().addTo(lmeState.map);
|
||
const navigationSheetPane = lmeState.map.createPane("navigationSheetPane");
|
||
navigationSheetPane.style.zIndex = "465";
|
||
navigationSheetPane.style.pointerEvents = "none";
|
||
const cadastreZoningPane = lmeState.map.createPane("cadastreZoningPane");
|
||
cadastreZoningPane.style.zIndex = "455";
|
||
cadastreZoningPane.style.pointerEvents = "none";
|
||
lmeState.navigationSheetLayer = L.layerGroup().addTo(lmeState.map);
|
||
lmeState.gpsTrail = L.polyline([], {
|
||
pane: "gpsTrailPane",
|
||
color: "#f97316",
|
||
weight: 4,
|
||
opacity: 0.85
|
||
}).addTo(lmeState.map);
|
||
lmeState.managerHighlight = L.layerGroup().addTo(lmeState.map);
|
||
lmeState.permanentLabels = L.layerGroup().addTo(lmeState.map);
|
||
|
||
refreshCadastreLayer();
|
||
bindFileImport();
|
||
bindDrawingEvents();
|
||
addGeometryToolsControl();
|
||
|
||
if (!lmeState.resizeBound) {
|
||
window.addEventListener("resize", resize);
|
||
window.addEventListener("orientationchange", resize);
|
||
lmeState.resizeBound = true;
|
||
}
|
||
|
||
lmeState.map.on("click", async event => {
|
||
if (event.originalEvent?._lmeDrawingSelected || Date.now() < lmeState.ignoreMapClickUntil) {
|
||
return;
|
||
}
|
||
hideDrawingScopeMenu();
|
||
clearManagerHighlight();
|
||
const info = await queryFeatureInfo(event.latlng);
|
||
if (info) {
|
||
L.popup().setLatLng(event.latlng).setContent(info).openOn(lmeState.map);
|
||
}
|
||
});
|
||
lmeState.map.on("moveend", () => {
|
||
void updateAzureAttribution();
|
||
scheduleMeasurementRefresh();
|
||
scheduleNavigationSheetRefresh();
|
||
});
|
||
|
||
document.getElementById("closeWmsPreview")?.addEventListener("click", closeCadastrePreview);
|
||
document.getElementById("wmsPreview")?.addEventListener("click", event => {
|
||
if (event.target?.id === "wmsPreview") {
|
||
closeCadastrePreview();
|
||
}
|
||
});
|
||
document.addEventListener("keydown", event => {
|
||
if (event.key === "Escape") {
|
||
closeCadastrePreview();
|
||
}
|
||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === "z") {
|
||
event.preventDefault();
|
||
undoMapChange();
|
||
} else if ((event.ctrlKey || event.metaKey) && (event.key.toLowerCase() === "y" || (event.shiftKey && event.key.toLowerCase() === "z"))) {
|
||
event.preventDefault();
|
||
redoMapChange();
|
||
}
|
||
});
|
||
window.addEventListener("beforeunload", event => {
|
||
if (!lmeState.isDirty) return;
|
||
event.preventDefault();
|
||
event.returnValue = "";
|
||
});
|
||
if (!lmeState.managerOutsideSelectionBound) {
|
||
document.addEventListener("pointerdown", event => {
|
||
if (lmeState.drawingScopeMenu
|
||
&& !event.target?.closest?.(".lme-drawing-scope-actions")
|
||
&& !event.target?.closest?.(".leaflet-draw-edit-edit, .leaflet-draw-edit-remove")) {
|
||
hideDrawingScopeMenu();
|
||
}
|
||
if (!lmeState.managerHighlightedObjectId && !lmeState.managerHighlightedParentKey) {
|
||
return;
|
||
}
|
||
if (event.target?.closest?.(".leaflet-draw")) {
|
||
return;
|
||
}
|
||
const selectedObject = event.target?.closest?.(".layer-manager-object");
|
||
const selectedParent = event.target?.closest?.("[data-highlight-key]");
|
||
if (selectedObject?.dataset.objectId === lmeState.managerHighlightedObjectId
|
||
|| selectedParent?.dataset.highlightKey === lmeState.managerHighlightedParentKey) {
|
||
return;
|
||
}
|
||
clearManagerHighlight();
|
||
});
|
||
lmeState.managerOutsideSelectionBound = true;
|
||
}
|
||
|
||
setInfo("Mappa pronta: satellite + catasto AdE");
|
||
}
|
||
|
||
function showSearchResult(latitude, longitude, label) {
|
||
const lat = Number(latitude);
|
||
const lon = Number(longitude);
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||
setInfo("La ricerca ha restituito coordinate non valide.");
|
||
return;
|
||
}
|
||
|
||
lmeState.map.setView([lat, lon], 18);
|
||
setInfo(label || `${lat.toFixed(6)}, ${lon.toFixed(6)}`);
|
||
}
|
||
|
||
function showInitialPosition(latitudeValue, longitudeValue, approximate, label) {
|
||
const latitude = Number(latitudeValue);
|
||
const longitude = Number(longitudeValue);
|
||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||
return;
|
||
}
|
||
|
||
const latlng = [latitude, longitude];
|
||
if (!approximate) {
|
||
lmeState.lastPosition = latlng;
|
||
}
|
||
lmeState.map.setView(latlng, approximate ? 13 : 19);
|
||
setText("gpsState", approximate
|
||
? "Posizione approssimativa dalla connessione - GPS non attivo"
|
||
: "Posizione iniziale acquisita - GPS non attivo");
|
||
setInfo(approximate
|
||
? `Mappa centrata in modo approssimativo su ${label || "questa zona"}.`
|
||
: "Mappa centrata sulla posizione attuale. GPS continuo non attivo.");
|
||
if (!approximate) {
|
||
updateGpsInsideState();
|
||
}
|
||
}
|
||
|
||
function displayOfficialParcel(xmlText, latitude, longitude, parcel, label, shouldFit) {
|
||
const lat = Number(latitude);
|
||
const lon = Number(longitude);
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||
setInfo("Coordinate della particella non valide.");
|
||
return "invalid";
|
||
}
|
||
|
||
try {
|
||
const geojson = gmlParcelsToGeoJson(xmlText);
|
||
const normalizedParcel = normalizeParcel(parcel);
|
||
const featureByNumber = normalizedParcel
|
||
? (geojson.features || []).find(item => {
|
||
const props = item.properties || {};
|
||
return String(props.LABEL || "").trim().toUpperCase() === normalizedParcel
|
||
|| String(props.NATIONALCADASTRALREFERENCE || "").toUpperCase().endsWith(`.${normalizedParcel}`);
|
||
})
|
||
: null;
|
||
const feature = featureByNumber
|
||
|| (geojson.features || []).find(item => geometryContainsPoint(item.geometry, [lon, lat]))
|
||
|| geojson.features?.[0];
|
||
|
||
if (!feature) {
|
||
if (shouldFit) {
|
||
lmeState.map.setView([lat, lon], 20);
|
||
}
|
||
setInfo("Posizione trovata, ma il servizio WFS dell'Agenzia non ha restituito il confine. Riprova tra poco.");
|
||
return "missing";
|
||
}
|
||
|
||
const ref = feature.properties?.NATIONALCADASTRALREFERENCE || feature.properties?.INSPIREID_LOCALID || label;
|
||
const targetGroup = normalizedParcel ? lmeState.selectedParcelsLayer : lmeState.gpsParcelLayer;
|
||
if (normalizedParcel) {
|
||
lmeState.selectedParcelsVisible = true;
|
||
lmeState.managerHiddenCategories.delete("selectedParcels");
|
||
if (!lmeState.map.hasLayer(targetGroup)) {
|
||
targetGroup.addTo(lmeState.map);
|
||
}
|
||
targetGroup.bringToFront();
|
||
}
|
||
if (!normalizedParcel) {
|
||
targetGroup.clearLayers();
|
||
}
|
||
|
||
const existingLayer = normalizedParcel
|
||
? findParcelLayerByReference(targetGroup, ref) || restoreHiddenParcelLayerByReference(ref)
|
||
: null;
|
||
if (existingLayer) {
|
||
applyStyleFromProperties(existingLayer);
|
||
targetGroup.bringToFront();
|
||
if (shouldFit) {
|
||
fitLayer(existingLayer);
|
||
}
|
||
setText("parcelState", `Particella selezionata: ${ref}`);
|
||
setInfo(`La particella ${ref} e' gia evidenziata sulla mappa.`);
|
||
updateGpsInsideState();
|
||
refreshLayerManager();
|
||
return "selected";
|
||
}
|
||
|
||
const importedRecord = normalizedParcel ? findImportedParcelRecord(feature, ref) : null;
|
||
if (importedRecord) {
|
||
restoreManagedRecordVisibility(importedRecord);
|
||
lmeState.importedVisible = true;
|
||
lmeState.managerHiddenCategories.delete("imported");
|
||
if (!lmeState.map.hasLayer(lmeState.imported)) {
|
||
lmeState.imported.addTo(lmeState.map);
|
||
}
|
||
lmeState.imported.bringToFront();
|
||
if (shouldFit) {
|
||
fitLayer(importedRecord.layer);
|
||
}
|
||
setText("parcelState", `Particella gia presente nei dati importati: ${ref}`);
|
||
setInfo(`La particella ${ref} e' gia presente in un progetto o file importato: nessun duplicato aggiunto.`);
|
||
refreshLayerManager();
|
||
return "imported";
|
||
}
|
||
|
||
feature.properties = {
|
||
...(feature.properties || {}),
|
||
_lmeParcelReference: String(ref || label || normalizedParcel),
|
||
_lmeColor: feature.properties?._lmeColor || "#ef4444",
|
||
_lmeFillColor: feature.properties?._lmeFillColor || "#fbbf24",
|
||
_lmeWeight: Number(feature.properties?._lmeWeight) || 4,
|
||
_lmeOpacity: Number.isFinite(Number(feature.properties?._lmeOpacity)) ? Number(feature.properties._lmeOpacity) : 1,
|
||
_lmeFillOpacity: Number.isFinite(Number(feature.properties?._lmeFillOpacity)) ? Number(feature.properties._lmeFillOpacity) : 0.26
|
||
};
|
||
const layer = L.geoJSON(feature, {
|
||
pane: normalizedParcel ? "selectedParcelsPane" : "gpsParcelsPane",
|
||
style: normalizedParcel
|
||
? { color: "#ef4444", weight: 4, opacity: 1, fillColor: "#fbbf24", fillOpacity: 0.26 }
|
||
: { color: "#1d4ed8", weight: 4, opacity: 1, fillColor: "#38bdf8", fillOpacity: 0.2 }
|
||
}).addTo(targetGroup);
|
||
layer.lmeParcelReference = ref;
|
||
layer.bindPopup(`<strong>${escapeHtml(ref)}</strong>`);
|
||
if (normalizedParcel) {
|
||
targetGroup.bringToFront();
|
||
}
|
||
if (shouldFit) {
|
||
fitLayer(layer);
|
||
}
|
||
|
||
if (normalizedParcel) {
|
||
setText("parcelState", `Particella selezionata: ${ref}`);
|
||
const count = lmeState.selectedParcelsLayer.getLayers().length;
|
||
setInfo(`Aggiunta ${ref}. Particelle evidenziate: ${count}.`);
|
||
} else {
|
||
setText("parcelState", `Dentro particella AdE: ${ref}`);
|
||
}
|
||
updateGpsInsideState();
|
||
refreshLayerManager();
|
||
return normalizedParcel ? "added" : "gps";
|
||
} catch (error) {
|
||
setInfo(`Geometria catastale non leggibile: ${error.message}`);
|
||
return "error";
|
||
}
|
||
}
|
||
|
||
function normalizeParcel(parcel) {
|
||
return String(parcel || "").trim().toUpperCase();
|
||
}
|
||
|
||
function findParcelLayerByReference(group, reference) {
|
||
const expected = normalizeParcelReference(reference);
|
||
let match = null;
|
||
group.eachLayer(layer => {
|
||
if (match) {
|
||
return;
|
||
}
|
||
if (parcelLayerMatchesReference(layer, expected)) {
|
||
match = layer;
|
||
return;
|
||
}
|
||
if (typeof layer.eachLayer === "function") {
|
||
match = findParcelLayerByReference(layer, reference);
|
||
}
|
||
});
|
||
return match;
|
||
}
|
||
|
||
function restoreHiddenParcelLayerByReference(reference) {
|
||
const expected = normalizeParcelReference(reference);
|
||
const entry = [...lmeState.managerHiddenObjects.entries()].find(([, record]) =>
|
||
record.categoryKey === "selectedParcels" && parcelLayerMatchesReference(record.layer, expected));
|
||
if (!entry) return null;
|
||
const [id, record] = entry;
|
||
lmeState.managerHiddenObjects.delete(id);
|
||
lmeState.managerHiddenObjectIds.delete(id);
|
||
lmeState.managerHiddenMeasurementSummaryIds.delete(id);
|
||
lmeState.managerHiddenMeasurementSideIds.delete(id);
|
||
record.parent?.addLayer?.(record.layer);
|
||
return record.layer;
|
||
}
|
||
|
||
function parcelLayerMatchesReference(layer, normalizedReference) {
|
||
const properties = layer?.feature?.properties || {};
|
||
return [
|
||
layer?.lmeParcelReference,
|
||
properties._lmeParcelReference,
|
||
properties.NATIONALCADASTRALREFERENCE,
|
||
properties.INSPIREID_LOCALID
|
||
].some(value => normalizeParcelReference(value) === normalizedReference);
|
||
}
|
||
|
||
function findImportedParcelRecord(feature, reference) {
|
||
const category = layerManagerCategories().find(item => item.key === "imported");
|
||
if (!category) return null;
|
||
const normalizedReference = normalizeParcelReference(reference);
|
||
return managedRecordsForCategory(category).find(record =>
|
||
parcelLayerMatchesReference(record.layer, normalizedReference)
|
||
|| parcelGeometriesEquivalent(record.layer?.toGeoJSON?.(), feature)) || null;
|
||
}
|
||
|
||
function restoreManagedRecordVisibility(record) {
|
||
if (!record) return;
|
||
lmeState.managerHiddenObjects.delete(record.id);
|
||
lmeState.managerHiddenObjectIds.delete(record.id);
|
||
lmeState.managerHiddenMeasurementSummaryIds.delete(record.id);
|
||
lmeState.managerHiddenMeasurementSideIds.delete(record.id);
|
||
if (record.parent && !record.parent.hasLayer?.(record.layer)) {
|
||
record.parent.addLayer?.(record.layer);
|
||
}
|
||
}
|
||
|
||
function parcelGeometriesEquivalent(candidate, target) {
|
||
const candidateType = candidate?.geometry?.type;
|
||
const targetType = target?.geometry?.type;
|
||
if (!["Polygon", "MultiPolygon"].includes(candidateType)
|
||
|| !["Polygon", "MultiPolygon"].includes(targetType)) {
|
||
return false;
|
||
}
|
||
try {
|
||
const candidateArea = turf.area(candidate);
|
||
const targetArea = turf.area(target);
|
||
if (!(candidateArea > 0) || !(targetArea > 0)) return false;
|
||
const areaRatio = Math.max(candidateArea, targetArea) / Math.min(candidateArea, targetArea);
|
||
if (areaRatio > 1.03) return false;
|
||
const overlap = turf.intersect(turf.featureCollection([candidate, target]));
|
||
return Boolean(overlap) && turf.area(overlap) / Math.min(candidateArea, targetArea) >= 0.985;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function normalizeParcelReference(reference) {
|
||
return String(reference || "").trim().toUpperCase().replace(/\s+/g, "");
|
||
}
|
||
|
||
function resize() {
|
||
if (!lmeState.map) {
|
||
return;
|
||
}
|
||
window.setTimeout(() => lmeState.map.invalidateSize({ pan: false }), 50);
|
||
}
|
||
|
||
function exportCadastreView() {
|
||
const layers = activeCadastreLayers();
|
||
if (!layers.length) {
|
||
setInfo("Attiva almeno un layer catastale prima di aprire la vista WMS.");
|
||
return;
|
||
}
|
||
|
||
const bounds = lmeState.map.getBounds();
|
||
const size = lmeState.map.getSize();
|
||
const scale = Math.min(1, 1200 / size.x, 1200 / size.y);
|
||
const width = Math.max(256, Math.round(size.x * scale));
|
||
const height = Math.max(256, Math.round(size.y * scale));
|
||
const bbox = [bounds.getSouth(), bounds.getWest(), bounds.getNorth(), bounds.getEast()].join(",");
|
||
const params = new URLSearchParams({
|
||
service: "WMS",
|
||
version: "1.3.0",
|
||
request: "GetMap",
|
||
layers: layers.join(","),
|
||
styles: "",
|
||
format: "image/png",
|
||
transparent: "true",
|
||
width: String(width),
|
||
height: String(height),
|
||
crs: "EPSG:4258",
|
||
bbox
|
||
});
|
||
const preview = document.getElementById("wmsPreview");
|
||
const previewBody = preview?.querySelector(".wms-preview-body");
|
||
const previewMap = document.getElementById("wmsPreviewMap");
|
||
const image = document.getElementById("wmsPreviewImage");
|
||
const overlay = document.getElementById("wmsPreviewOverlay");
|
||
const loading = document.getElementById("wmsPreviewLoading");
|
||
if (!preview || !previewBody || !previewMap || !image || !overlay || !loading) {
|
||
setInfo("Vista WMS interna non disponibile.");
|
||
return;
|
||
}
|
||
|
||
preview.hidden = false;
|
||
const availableWidth = Math.max(1, previewBody.clientWidth - 24);
|
||
const availableHeight = Math.max(1, previewBody.clientHeight - 24);
|
||
const displayScale = Math.min(1, availableWidth / width, availableHeight / height);
|
||
previewMap.style.width = `${Math.max(1, Math.round(width * displayScale))}px`;
|
||
previewMap.style.height = `${Math.max(1, Math.round(height * displayScale))}px`;
|
||
previewMap.hidden = true;
|
||
renderPreviewParcelOverlay(overlay, bounds, width, height);
|
||
|
||
loading.textContent = "Caricamento...";
|
||
loading.hidden = false;
|
||
image.onload = () => {
|
||
loading.hidden = true;
|
||
previewMap.hidden = false;
|
||
const highlightedCount = lmeState.selectedParcelsLayer.getLayers().length
|
||
+ lmeState.gpsParcelLayer.getLayers().length;
|
||
const zoomHint = layers.includes("codice_plla") && lmeState.map.getZoom() < 17
|
||
? " Ingrandisci almeno fino a zoom 17 per leggere i numeri delle particelle."
|
||
: "";
|
||
setInfo(`Vista WMS caricata con ${layers.length} layer e ${highlightedCount} particelle evidenziate.${zoomHint}`);
|
||
};
|
||
image.onerror = () => {
|
||
previewMap.hidden = true;
|
||
loading.textContent = "Il servizio WMS non ha restituito l'immagine. Riprova tra poco.";
|
||
setInfo("Vista WMS non disponibile.");
|
||
};
|
||
image.src = `${cadastreUrl()}${params}`;
|
||
}
|
||
|
||
function closeCadastrePreview() {
|
||
const preview = document.getElementById("wmsPreview");
|
||
const previewMap = document.getElementById("wmsPreviewMap");
|
||
const image = document.getElementById("wmsPreviewImage");
|
||
const overlay = document.getElementById("wmsPreviewOverlay");
|
||
if (preview) {
|
||
preview.hidden = true;
|
||
}
|
||
if (previewMap) {
|
||
previewMap.hidden = true;
|
||
}
|
||
if (image) {
|
||
image.onload = null;
|
||
image.onerror = null;
|
||
image.removeAttribute("src");
|
||
}
|
||
overlay?.replaceChildren();
|
||
}
|
||
|
||
function renderPreviewParcelOverlay(svg, bounds, width, height) {
|
||
svg.replaceChildren();
|
||
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||
appendPreviewGeometry(svg, lmeState.selectedParcelsLayer.toGeoJSON(), "wms-preview-parcel", bounds, width, height);
|
||
appendPreviewGeometry(svg, lmeState.gpsParcelLayer.toGeoJSON(), "wms-preview-gps-parcel", bounds, width, height);
|
||
}
|
||
|
||
function appendPreviewGeometry(svg, featureCollection, className, bounds, width, height) {
|
||
const namespace = "http://www.w3.org/2000/svg";
|
||
for (const feature of featureCollection?.features || []) {
|
||
for (const polygon of geometryPolygons(feature.geometry)) {
|
||
const pathData = polygon
|
||
.map(ring => ring
|
||
.map((coordinate, index) => {
|
||
const point = previewPixel(coordinate, bounds, width, height);
|
||
return `${index ? "L" : "M"}${point.x.toFixed(2)} ${point.y.toFixed(2)}`;
|
||
})
|
||
.join(" ") + " Z")
|
||
.join(" ");
|
||
if (!pathData.trim()) {
|
||
continue;
|
||
}
|
||
const path = document.createElementNS(namespace, "path");
|
||
path.setAttribute("d", pathData);
|
||
path.setAttribute("class", className);
|
||
svg.appendChild(path);
|
||
}
|
||
}
|
||
}
|
||
|
||
function geometryPolygons(geometry) {
|
||
if (!geometry) {
|
||
return [];
|
||
}
|
||
if (geometry.type === "Polygon") {
|
||
return [geometry.coordinates || []];
|
||
}
|
||
if (geometry.type === "MultiPolygon") {
|
||
return geometry.coordinates || [];
|
||
}
|
||
if (geometry.type === "GeometryCollection") {
|
||
return (geometry.geometries || []).flatMap(geometryPolygons);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function previewPixel(coordinate, bounds, width, height) {
|
||
const longitude = Number(coordinate?.[0]);
|
||
const latitude = Number(coordinate?.[1]);
|
||
return {
|
||
x: ((longitude - bounds.getWest()) / (bounds.getEast() - bounds.getWest())) * width,
|
||
y: ((bounds.getNorth() - latitude) / (bounds.getNorth() - bounds.getSouth())) * height
|
||
};
|
||
}
|
||
|
||
function bindFileImport() {
|
||
if (lmeState.fileImportBound) {
|
||
return;
|
||
}
|
||
|
||
document.addEventListener("change", async event => {
|
||
if (event.target?.id !== "gisFileInput") {
|
||
return;
|
||
}
|
||
|
||
const file = event.target.files?.[0];
|
||
if (!file) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (file.size > 100 * 1024 * 1024) throw new Error("file superiore a 100 MB; suddividilo prima dell'importazione");
|
||
setInfo(`Analisi di ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)...`);
|
||
await new Promise(resolve => window.setTimeout(resolve, 20));
|
||
await importGisFile(file);
|
||
} catch (error) {
|
||
setInfo(`File non leggibile: ${error.message}`);
|
||
} finally {
|
||
event.target.value = "";
|
||
}
|
||
});
|
||
lmeState.fileImportBound = true;
|
||
}
|
||
|
||
async function importExternalGisFile(fileName, base64Content, mimeType) {
|
||
const binary = atob(base64Content);
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let index = 0; index < binary.length; index++) {
|
||
bytes[index] = binary.charCodeAt(index);
|
||
}
|
||
const file = new File([bytes], fileName, { type: mimeType || "application/octet-stream" });
|
||
if (file.size > 100 * 1024 * 1024) {
|
||
throw new Error("file superiore a 100 MB; suddividilo prima dell'importazione");
|
||
}
|
||
setInfo(`Analisi di ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)...`);
|
||
await new Promise(resolve => window.setTimeout(resolve, 20));
|
||
return importGisFile(file);
|
||
}
|
||
|
||
async function importGisFile(file) {
|
||
const result = await readGisFile(file);
|
||
rememberMapChange();
|
||
const geojson = result.geojson;
|
||
const parcelLayer = addImportedFeatures(geojson);
|
||
const sheetGeojson = result.sheetGeojson || emptyFeatureCollection();
|
||
const sheetLayer = addImportedSheetFeatures(sheetGeojson);
|
||
applyImportedSheetVisibility();
|
||
fitLayer(geojson.features.length ? parcelLayer : sheetLayer);
|
||
refreshImportedMeasurements();
|
||
const featureCount = geojson.features.length;
|
||
const sheetCount = sheetGeojson.features.length;
|
||
const ignored = result.ignoredCount
|
||
? `; esclusi ${result.ignoredCount} elementi del foglio o non particella`
|
||
: "";
|
||
const includedSheets = sheetCount
|
||
? `; ${sheetCount} elementi foglio ${lmeState.importedSheetsVisible ? "visibili" : "nascosti"}`
|
||
: "";
|
||
const reprojected = result.reprojected ? "; coordinate Web Mercator convertite" : "";
|
||
const warnings = result.validationWarnings ? `; ${result.validationWarnings} geometrie da verificare` : "";
|
||
const message = `Caricato ${file.name}: ${featureCount} particelle/elementi${includedSheets}${ignored}${reprojected}${warnings}.`;
|
||
setInfo(message);
|
||
updateGpsInsideState();
|
||
refreshLayerManager();
|
||
finishMapChange();
|
||
return message;
|
||
}
|
||
|
||
async function readGisFile(file) {
|
||
const lowerName = file.name.toLowerCase();
|
||
|
||
if (lowerName.endsWith(".geojson") || lowerName.endsWith(".json")) {
|
||
const text = (await readFileText(file)).replace(/^\uFEFF/, "").trim();
|
||
if (!text) {
|
||
throw new Error("GeoJSON vuoto");
|
||
}
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(text);
|
||
if (typeof parsed === "string") {
|
||
parsed = JSON.parse(parsed);
|
||
}
|
||
} catch (error) {
|
||
throw new Error(`GeoJSON non valido: ${error.message}`);
|
||
}
|
||
return normalizeGeoJson(parsed, file.name);
|
||
}
|
||
|
||
if (lowerName.endsWith(".kml")) {
|
||
return normalizeKmlFiles([{ name: file.name, text: await readFileText(file) }]);
|
||
}
|
||
|
||
if (lowerName.endsWith(".kmz")) {
|
||
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||
const kmlEntries = Object.values(zip.files).filter(entry =>
|
||
!entry.dir
|
||
&& !entry.name.startsWith("__MACOSX/")
|
||
&& entry.name.toLowerCase().endsWith(".kml"));
|
||
if (!kmlEntries.length) {
|
||
throw new Error("KMZ senza file KML interno");
|
||
}
|
||
const files = await Promise.all(kmlEntries.map(async entry => ({
|
||
name: entry.name,
|
||
text: await entry.async("text")
|
||
})));
|
||
return normalizeKmlFiles(files);
|
||
}
|
||
|
||
throw new Error("Formato supportato: KML, KMZ, GeoJSON");
|
||
}
|
||
|
||
function readFileText(file) {
|
||
if (typeof file.text === "function") {
|
||
return file.text();
|
||
}
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(String(reader.result || ""));
|
||
reader.onerror = () => reject(new Error("lettura del file non riuscita"));
|
||
reader.readAsText(file);
|
||
});
|
||
}
|
||
|
||
function normalizeGeoJson(value, fileName) {
|
||
let root;
|
||
if (Array.isArray(value)) {
|
||
root = {
|
||
type: "FeatureCollection",
|
||
features: value.map(item => item?.type === "Feature"
|
||
? item
|
||
: { type: "Feature", properties: {}, geometry: item })
|
||
};
|
||
} else if (value?.type === "FeatureCollection") {
|
||
root = value;
|
||
} else if (value?.type === "Feature") {
|
||
root = { type: "FeatureCollection", features: [value], crs: value.crs };
|
||
} else if (isGeoJsonGeometry(value)) {
|
||
root = {
|
||
type: "FeatureCollection",
|
||
features: [{ type: "Feature", properties: {}, geometry: value }],
|
||
crs: value.crs
|
||
};
|
||
} else {
|
||
throw new Error("il contenuto non e' un Feature, una geometria o una FeatureCollection");
|
||
}
|
||
|
||
root.features = (root.features || []).filter(feature => feature?.type === "Feature" && feature.geometry);
|
||
if (!root.features.length) {
|
||
throw new Error("nessuna geometria presente nel GeoJSON");
|
||
}
|
||
root.features.forEach(feature => {
|
||
feature.properties = feature.properties && typeof feature.properties === "object"
|
||
? feature.properties
|
||
: {};
|
||
feature.properties._lmeSource = feature.properties._lmeSource || fileName;
|
||
});
|
||
|
||
const crsText = root.crs ? JSON.stringify(root.crs).toUpperCase() : "";
|
||
const coordinates = collectGeoJsonCoordinates(root);
|
||
const hasProjectedCoordinates = coordinates.some(point => Math.abs(point[0]) > 180 || Math.abs(point[1]) > 90);
|
||
const webMercator = /EPSG[^0-9]*(3857|900913)/.test(crsText)
|
||
|| (!crsText && hasProjectedCoordinates && coordinates.every(point =>
|
||
Math.abs(point[0]) <= 20037508.35 && Math.abs(point[1]) <= 20048966.1));
|
||
|
||
if (webMercator) {
|
||
root.features.forEach(feature => {
|
||
feature.geometry = transformGeoJsonGeometry(feature.geometry, webMercatorToWgs84);
|
||
});
|
||
} else if (crsText && !/(4326|4258|CRS84|CRS:84)/.test(crsText)) {
|
||
throw new Error(`sistema di coordinate non supportato (${crsText.slice(0, 80)})`);
|
||
}
|
||
|
||
let validationWarnings = 0;
|
||
const unique = new Set();
|
||
root.features = root.features.filter(feature => {
|
||
try { turf.cleanCoords(feature, { mutate: true }); } catch { }
|
||
const key = JSON.stringify(feature.geometry);
|
||
if (unique.has(key)) return false;
|
||
unique.add(key);
|
||
if (typeof turf.booleanValid === "function" && !turf.booleanValid(feature)) {
|
||
feature.properties._lmeValidationWarning = "Geometria non valida: usa Controlla e ripara";
|
||
validationWarnings++;
|
||
}
|
||
return true;
|
||
});
|
||
if (root.features.length > 5000 && window.confirm(`Il file contiene ${root.features.length} geometrie. Semplificarle leggermente per migliorare le prestazioni?`)) {
|
||
root.features = root.features.map(feature => {
|
||
try { return turf.simplify(feature, { tolerance: 0.000001, highQuality: true, mutate: false }); } catch { return feature; }
|
||
});
|
||
}
|
||
|
||
const normalizedCoordinates = collectGeoJsonCoordinates(root);
|
||
if (!normalizedCoordinates.length || normalizedCoordinates.some(point =>
|
||
!Number.isFinite(point[0])
|
||
|| !Number.isFinite(point[1])
|
||
|| Math.abs(point[0]) > 180
|
||
|| Math.abs(point[1]) > 90)) {
|
||
throw new Error("coordinate non valide o non espresse in WGS84/EPSG:4326");
|
||
}
|
||
|
||
delete root.crs;
|
||
return {
|
||
geojson: root,
|
||
sheetGeojson: emptyFeatureCollection(),
|
||
ignoredCount: 0,
|
||
reprojected: webMercator,
|
||
validationWarnings
|
||
};
|
||
}
|
||
|
||
function isGeoJsonGeometry(value) {
|
||
return [
|
||
"Point",
|
||
"MultiPoint",
|
||
"LineString",
|
||
"MultiLineString",
|
||
"Polygon",
|
||
"MultiPolygon",
|
||
"GeometryCollection"
|
||
].includes(value?.type);
|
||
}
|
||
|
||
function collectGeoJsonCoordinates(geojson) {
|
||
const points = [];
|
||
const visitGeometry = geometry => {
|
||
if (!geometry) {
|
||
return;
|
||
}
|
||
if (geometry.type === "GeometryCollection") {
|
||
(geometry.geometries || []).forEach(visitGeometry);
|
||
return;
|
||
}
|
||
const visit = coordinates => {
|
||
if (Array.isArray(coordinates)
|
||
&& coordinates.length >= 2
|
||
&& typeof coordinates[0] === "number"
|
||
&& typeof coordinates[1] === "number") {
|
||
points.push(coordinates);
|
||
return;
|
||
}
|
||
if (Array.isArray(coordinates)) {
|
||
coordinates.forEach(visit);
|
||
}
|
||
};
|
||
visit(geometry.coordinates);
|
||
};
|
||
(geojson.features || []).forEach(feature => visitGeometry(feature.geometry));
|
||
return points;
|
||
}
|
||
|
||
function transformGeoJsonGeometry(geometry, transform) {
|
||
if (!geometry) {
|
||
return geometry;
|
||
}
|
||
if (geometry.type === "GeometryCollection") {
|
||
return {
|
||
...geometry,
|
||
geometries: (geometry.geometries || []).map(item => transformGeoJsonGeometry(item, transform))
|
||
};
|
||
}
|
||
const visit = coordinates => {
|
||
if (Array.isArray(coordinates)
|
||
&& coordinates.length >= 2
|
||
&& typeof coordinates[0] === "number"
|
||
&& typeof coordinates[1] === "number") {
|
||
return transform(coordinates);
|
||
}
|
||
return Array.isArray(coordinates) ? coordinates.map(visit) : coordinates;
|
||
};
|
||
return { ...geometry, coordinates: visit(geometry.coordinates) };
|
||
}
|
||
|
||
function webMercatorToWgs84(coordinate) {
|
||
const longitude = coordinate[0] * 180 / 20037508.34;
|
||
const latitudeRadians = Math.PI / 2 - 2 * Math.atan(Math.exp(-coordinate[1] / 6378137));
|
||
return [longitude, latitudeRadians * 180 / Math.PI, ...coordinate.slice(2)];
|
||
}
|
||
|
||
function normalizeKmlFiles(files) {
|
||
const features = files.flatMap(file => kmlTextToGeoJson(file.text, file.name));
|
||
if (!features.length) {
|
||
throw new Error("KML senza geometrie utilizzabili");
|
||
}
|
||
|
||
if (!looksLikeCadastralKml(features)) {
|
||
return {
|
||
geojson: { type: "FeatureCollection", features },
|
||
sheetGeojson: emptyFeatureCollection(),
|
||
ignoredCount: 0,
|
||
reprojected: false
|
||
};
|
||
}
|
||
|
||
const polygonParts = splitKmlPolygonFeatures(features);
|
||
const parcels = filterKmlParcelFeatures(polygonParts);
|
||
const parcelSet = new Set(parcels);
|
||
const sheetParts = polygonParts.filter(feature => !parcelSet.has(feature));
|
||
const nonPolygonFeatures = features.filter(feature => geometryPolygons(feature.geometry).length === 0);
|
||
const importedFeatures = [...parcels, ...nonPolygonFeatures];
|
||
const optionalFeatures = sheetParts;
|
||
if (!importedFeatures.length && !(lmeState.includeKmlSheetLayer && optionalFeatures.length)) {
|
||
throw new Error("nessuna particella trovata; attiva 'includi layer del foglio' per importare gli altri elementi");
|
||
}
|
||
return {
|
||
geojson: { type: "FeatureCollection", features: importedFeatures },
|
||
sheetGeojson: {
|
||
type: "FeatureCollection",
|
||
features: lmeState.includeKmlSheetLayer ? optionalFeatures : []
|
||
},
|
||
ignoredCount: lmeState.includeKmlSheetLayer ? 0 : optionalFeatures.length,
|
||
reprojected: false
|
||
};
|
||
}
|
||
|
||
function looksLikeCadastralKml(features) {
|
||
return features.some(feature => {
|
||
const properties = feature?.properties || {};
|
||
const name = String(properties.name || properties.Name || properties.LABEL || "").trim();
|
||
const context = [
|
||
name,
|
||
properties.layer,
|
||
properties.Layer,
|
||
properties.type,
|
||
properties.Tipo,
|
||
properties._lmeFolder
|
||
].filter(Boolean).join(" ").toLowerCase();
|
||
return /\b(particell|mappal|catast|foglio|parcel|cadastral)\w*/i.test(context);
|
||
});
|
||
}
|
||
|
||
function kmlTextToGeoJson(text, sourceName) {
|
||
const xml = new DOMParser().parseFromString(text, "text/xml");
|
||
if (xml.getElementsByTagName("parsererror").length) {
|
||
throw new Error(`KML non valido (${sourceName})`);
|
||
}
|
||
const tree = toGeoJSON.kmlWithFolders(xml, { skipNullGeometry: true });
|
||
const features = [];
|
||
const visit = (node, folders) => {
|
||
if (node?.type === "Feature") {
|
||
const sourceProperties = node.properties || {};
|
||
node.properties = {
|
||
...sourceProperties,
|
||
_lmeColor: sourceProperties._lmeColor || sourceProperties.stroke,
|
||
_lmeFillColor: sourceProperties._lmeFillColor || sourceProperties.fill,
|
||
_lmeWeight: sourceProperties._lmeWeight || sourceProperties["stroke-width"],
|
||
_lmeOpacity: sourceProperties._lmeOpacity || sourceProperties["stroke-opacity"],
|
||
_lmeFillOpacity: sourceProperties._lmeFillOpacity || sourceProperties["fill-opacity"],
|
||
_lmeFolder: folders.filter(Boolean).join(" / "),
|
||
_lmeSource: sourceName
|
||
};
|
||
features.push(node);
|
||
return;
|
||
}
|
||
const nextFolders = node?.type === "folder"
|
||
? [...folders, node.meta?.name || ""]
|
||
: folders;
|
||
(node?.children || []).forEach(child => visit(child, nextFolders));
|
||
};
|
||
visit(tree, []);
|
||
return features;
|
||
}
|
||
|
||
function filterKmlParcelFeatures(features) {
|
||
const filtered = features.filter(feature => {
|
||
if (isSheetOrOverlayFeature(feature)) {
|
||
return false;
|
||
}
|
||
const properties = feature.properties || {};
|
||
const name = String(properties.name || properties.Name || properties.LABEL || "").trim();
|
||
const identity = [name, properties.layer, properties.Layer, properties.type, properties.Tipo]
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
.toLowerCase();
|
||
const folder = String(properties._lmeFolder || "").toLowerCase();
|
||
const context = `${identity} ${folder}`;
|
||
const hasParcelNumber = /^[a-z]?\s*\d+[a-z]?(?:[\/. -]\d+[a-z]?)?$/i.test(name);
|
||
const isSheet = /\bfoglio\b|\bsheet\b|quadro[ _-]?d[' ]?unione|(?:perimetro|bordo|limite|contorno)[ _-]?(?:del[ _-]?)?foglio|sheet[ _-]?boundary/.test(context);
|
||
return !isSheet || hasParcelNumber;
|
||
});
|
||
return removeLikelySheetOutlines(filtered);
|
||
}
|
||
|
||
function isSheetOrOverlayFeature(feature) {
|
||
const properties = feature?.properties || {};
|
||
const geometryType = String(properties["@geometry-type"] || "").toLowerCase();
|
||
const name = String(properties.name || properties.Name || properties.LABEL || "").trim();
|
||
const context = [
|
||
name,
|
||
properties.layer,
|
||
properties.Layer,
|
||
properties.type,
|
||
properties.Tipo,
|
||
properties._lmeFolder
|
||
].filter(Boolean).join(" ").toLowerCase();
|
||
|
||
if (geometryType === "groundoverlay" || /^doc[_ -]?\d+$/i.test(name)) {
|
||
return true;
|
||
}
|
||
|
||
const hasParcelNumber = /^[a-z]?\s*\d+[a-z]?(?:[\/. -]\d+[a-z]?)?$/i.test(name);
|
||
return !hasParcelNumber
|
||
&& /\bfoglio\b|\bsheet\b|quadro[ _-]?d[' ]?unione|(?:perimetro|bordo|limite|contorno)[ _-]?(?:del[ _-]?)?foglio|sheet[ _-]?boundary/.test(context);
|
||
}
|
||
|
||
function splitKmlPolygonFeatures(features) {
|
||
const result = [];
|
||
const addGeometry = (feature, geometry, partIndex) => {
|
||
if (!geometry) {
|
||
return;
|
||
}
|
||
if (geometry.type === "Polygon") {
|
||
result.push({
|
||
...feature,
|
||
properties: { ...(feature.properties || {}), _lmePart: partIndex },
|
||
geometry
|
||
});
|
||
return;
|
||
}
|
||
if (geometry.type === "MultiPolygon") {
|
||
(geometry.coordinates || []).forEach((coordinates, index) => addGeometry(
|
||
feature,
|
||
{ type: "Polygon", coordinates },
|
||
`${partIndex}.${index}`));
|
||
return;
|
||
}
|
||
if (geometry.type === "GeometryCollection") {
|
||
(geometry.geometries || []).forEach((item, index) => addGeometry(feature, item, `${partIndex}.${index}`));
|
||
}
|
||
};
|
||
features.forEach((feature, index) => addGeometry(feature, feature.geometry, index));
|
||
return result;
|
||
}
|
||
|
||
function removeLikelySheetOutlines(features) {
|
||
let remaining = [...features];
|
||
while (remaining.length >= 2) {
|
||
const candidates = remaining
|
||
.map(feature => ({ feature, bounds: geoJsonBounds(feature.geometry) }))
|
||
.filter(item => item.bounds);
|
||
if (candidates.length < 2) {
|
||
break;
|
||
}
|
||
|
||
const largest = candidates.reduce((current, item) =>
|
||
boundsArea(item.bounds) > boundsArea(current.bounds) ? item : current);
|
||
if (isStrongParcelFeature(largest.feature)) {
|
||
break;
|
||
}
|
||
const others = candidates.filter(item => item !== largest);
|
||
const comparisonAreas = others.map(item => boundsArea(item.bounds)).sort((a, b) => a - b);
|
||
const referenceArea = comparisonAreas[Math.floor(comparisonAreas.length / 2)];
|
||
const contained = others.filter(item => boundsContains(largest.bounds, boundsCenter(item.bounds))).length;
|
||
const minimumContained = candidates.length === 2 ? 1 : Math.ceil(others.length * 0.6);
|
||
const areaFactor = 2.5;
|
||
const looksLikeSheet = referenceArea > 0
|
||
&& boundsArea(largest.bounds) > referenceArea * areaFactor
|
||
&& contained >= minimumContained;
|
||
if (!looksLikeSheet) {
|
||
break;
|
||
}
|
||
remaining = remaining.filter(feature => feature !== largest.feature);
|
||
}
|
||
return remaining;
|
||
}
|
||
|
||
function isStrongParcelFeature(feature) {
|
||
const properties = feature?.properties || {};
|
||
const name = String(properties.name || properties.Name || properties.LABEL || "").trim();
|
||
const context = [name, properties.layer, properties.Layer, properties.type, properties.Tipo, properties._lmeFolder]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
return /particell|mappal|parcel/i.test(context)
|
||
|| /^[a-z]?\s*\d+[a-z]?(?:[\/. -]\d+[a-z]?)?$/i.test(name);
|
||
}
|
||
|
||
function geoJsonBounds(geometry) {
|
||
const wrapper = { features: [{ geometry }] };
|
||
const points = collectGeoJsonCoordinates(wrapper);
|
||
if (!points.length) {
|
||
return null;
|
||
}
|
||
return points.reduce((bounds, point) => ({
|
||
west: Math.min(bounds.west, point[0]),
|
||
south: Math.min(bounds.south, point[1]),
|
||
east: Math.max(bounds.east, point[0]),
|
||
north: Math.max(bounds.north, point[1])
|
||
}), { west: Infinity, south: Infinity, east: -Infinity, north: -Infinity });
|
||
}
|
||
|
||
function boundsArea(bounds) {
|
||
return Math.max(0, bounds.east - bounds.west) * Math.max(0, bounds.north - bounds.south);
|
||
}
|
||
|
||
function boundsCenter(bounds) {
|
||
return [(bounds.west + bounds.east) / 2, (bounds.south + bounds.north) / 2];
|
||
}
|
||
|
||
function boundsContains(bounds, point) {
|
||
return point[0] >= bounds.west && point[0] <= bounds.east
|
||
&& point[1] >= bounds.south && point[1] <= bounds.north;
|
||
}
|
||
|
||
function setIncludeKmlSheetLayer(enabled) {
|
||
lmeState.includeKmlSheetLayer = Boolean(enabled);
|
||
setInfo(lmeState.includeKmlSheetLayer
|
||
? "Importazione KML/KMZ: verranno inclusi anche foglio e altri elementi."
|
||
: "Importazione KML/KMZ: verranno caricate solo le particelle riconosciute.");
|
||
}
|
||
|
||
function setImportedLayerVisible(enabled) {
|
||
lmeState.importedVisible = Boolean(enabled);
|
||
if (!lmeState.map || !lmeState.imported) {
|
||
return;
|
||
}
|
||
|
||
if (lmeState.importedVisible) {
|
||
if (!lmeState.map.hasLayer(lmeState.imported)) {
|
||
lmeState.imported.addTo(lmeState.map);
|
||
}
|
||
lmeState.imported.bringToFront();
|
||
const count = lmeState.imported.toGeoJSON().features?.length || 0;
|
||
setInfo(count
|
||
? `${count} particelle o geometrie importate visibili.`
|
||
: "Nessun dato importato da mostrare.");
|
||
return;
|
||
}
|
||
|
||
if (lmeState.map.hasLayer(lmeState.imported)) {
|
||
lmeState.map.removeLayer(lmeState.imported);
|
||
}
|
||
setInfo("Dati importati nascosti; restano disponibili nel file o progetto.");
|
||
}
|
||
|
||
function managedLayerPanes() {
|
||
return {
|
||
gpsPosition: "gpsPositionPane",
|
||
drawings: "drawingsPane",
|
||
gpsTrail: "gpsTrailPane",
|
||
gpsParcels: "gpsParcelsPane",
|
||
selectedParcels: "selectedParcelsPane",
|
||
fiducials: "fiducialsPane",
|
||
importedSheets: "importedSheetPane",
|
||
imported: "importedPane",
|
||
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)) {
|
||
const remaining = saved.filter(key => !["gpsPosition", "gpsTrail", "fiducials"].includes(key));
|
||
lmeState.layerOrder = ["gpsPosition", "gpsTrail", "fiducials", ...remaining];
|
||
}
|
||
} catch {
|
||
}
|
||
}
|
||
|
||
function saveLayerManagerState() {
|
||
// Object order and visibility are serialized in the project snapshot.
|
||
}
|
||
|
||
function applyStoredLayerManagerVisibility() {
|
||
layerManagerCategories().forEach(category => {
|
||
if (lmeState.managerHiddenCategories.has(category.key)) {
|
||
category.roots.forEach(layer => {
|
||
if (layer && lmeState.map.hasLayer(layer)) {
|
||
lmeState.map.removeLayer(layer);
|
||
}
|
||
});
|
||
}
|
||
managedRecordsForCategory(category).forEach(record => {
|
||
if (lmeState.managerHiddenObjectIds.has(record.id)
|
||
&& !lmeState.managerHiddenObjects.has(record.id)) {
|
||
lmeState.managerHiddenObjects.set(record.id, record);
|
||
record.parent.removeLayer(record.layer);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function managedGeoJsonLabel(geojson, fallback) {
|
||
const features = geojson?.features || [];
|
||
const source = features.map(feature => feature?.properties?._lmeSource).find(Boolean);
|
||
return `${source || fallback} (${features.length})`;
|
||
}
|
||
|
||
function drawingLayerLabel(layer, layerType) {
|
||
const properties = layer?.feature?.properties || {};
|
||
if (properties.name) {
|
||
return `POI: ${properties.name}`;
|
||
}
|
||
const shape = layerType || properties._lmeShape || "disegno";
|
||
const labels = {
|
||
polygon: "Area",
|
||
rectangle: "Rettangolo",
|
||
polyline: "Linea",
|
||
circle: "Cerchio",
|
||
marker: "POI"
|
||
};
|
||
return labels[shape] || "Disegno";
|
||
}
|
||
|
||
function layerManagerCategories() {
|
||
return [
|
||
{ key: "gpsPosition", label: "Posizione GPS", roots: [lmeState.gpsMarker, lmeState.gpsAccuracy].filter(Boolean) },
|
||
{ key: "drawings", label: "Disegni e POI", roots: [lmeState.drawings].filter(Boolean), blocks: true },
|
||
{ key: "gpsTrail", label: "Traccia GPS", roots: [lmeState.gpsTrail].filter(Boolean) },
|
||
{ key: "gpsParcels", label: "Particella rilevata dal GPS", roots: [lmeState.gpsParcelLayer].filter(Boolean) },
|
||
{ key: "selectedParcels", label: "Particelle catastali evidenziate", roots: [lmeState.selectedParcelsLayer].filter(Boolean) },
|
||
{ key: "fiducials", label: "Punti fiduciali", roots: [lmeState.fiducials].filter(Boolean), blocks: true },
|
||
{ key: "importedSheets", label: "Cornici e fogli importati", roots: [lmeState.importedSheets].filter(Boolean), blocks: true },
|
||
{ key: "imported", label: "Progetti e dati importati", roots: [lmeState.imported].filter(Boolean), blocks: true },
|
||
{ key: "cadastre", label: "Layer catastali AdE", roots: [lmeState.cadastre].filter(Boolean) }
|
||
];
|
||
}
|
||
|
||
function managedLayerId(layer, categoryKey) {
|
||
const properties = layer?.feature?.properties;
|
||
if (properties) {
|
||
properties._lmeManagerId ||= `${categoryKey}-${L.stamp(layer)}`;
|
||
return properties._lmeManagerId;
|
||
}
|
||
layer.lmeManagerId ||= `${categoryKey}-${L.stamp(layer)}`;
|
||
return layer.lmeManagerId;
|
||
}
|
||
|
||
function collectManagedLeafLayers(layer, categoryKey, parent, records) {
|
||
if (!layer) {
|
||
return;
|
||
}
|
||
if (typeof layer.getLayers === "function") {
|
||
const children = layer.getLayers();
|
||
children.forEach(child => collectManagedLeafLayers(child, categoryKey, layer, records));
|
||
return;
|
||
}
|
||
if (layer instanceof L.Polyline && !lineLatLngGroups(layer.getLatLngs()).some(line => line.length)) {
|
||
return;
|
||
}
|
||
const id = managedLayerId(layer, categoryKey);
|
||
records.push({
|
||
id,
|
||
categoryKey,
|
||
layer,
|
||
parent,
|
||
source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente")
|
||
});
|
||
}
|
||
|
||
function managedRecordsForCategory(category) {
|
||
const records = [];
|
||
category.roots.forEach(root => collectManagedLeafLayers(root, category.key, lmeState.map, records));
|
||
lmeState.managerHiddenObjects.forEach(record => {
|
||
if (record.categoryKey === category.key && !records.some(item => item.id === record.id)) {
|
||
records.push(record);
|
||
}
|
||
});
|
||
return records;
|
||
}
|
||
|
||
function managedObjectLabel(record, index) {
|
||
const layer = record.layer;
|
||
const properties = layer?.feature?.properties || {};
|
||
const reference = properties._lmeDisplayName
|
||
|| properties.name
|
||
|| properties.Name
|
||
|| properties.NATIONALCADASTRALREFERENCE
|
||
|| properties.INSPIREID_LOCALID
|
||
|| properties.LABEL
|
||
|| properties.label;
|
||
if (reference) {
|
||
return String(reference);
|
||
}
|
||
if (layer.lmeManagerLabel) {
|
||
return layer.lmeManagerLabel;
|
||
}
|
||
if (layer instanceof L.Circle) {
|
||
return `Cerchio ${index + 1}`;
|
||
}
|
||
if (layer instanceof L.Polygon) {
|
||
return `Area/particella ${index + 1}`;
|
||
}
|
||
if (layer instanceof L.Polyline) {
|
||
return `Linea ${index + 1}`;
|
||
}
|
||
if (layer instanceof L.Marker) {
|
||
return `POI ${index + 1}`;
|
||
}
|
||
return `Oggetto ${index + 1}`;
|
||
}
|
||
|
||
function managedObjectEditableName(record) {
|
||
const properties = record?.layer?.feature?.properties || {};
|
||
return properties._lmeDisplayName || properties.name || properties.Name || properties.label || properties.LABEL || "";
|
||
}
|
||
|
||
function managedObjectCanRename(record) {
|
||
return ["drawings", "imported", "importedSheets"].includes(record.categoryKey)
|
||
&& record.layer?.feature?.properties;
|
||
}
|
||
|
||
async function renameManagedObject(record, index) {
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||
if (!managedObjectCanRename(record)) {
|
||
return;
|
||
}
|
||
const currentName = String(managedObjectEditableName(record) || managedObjectLabel(record, index)).trim();
|
||
const result = await showFormDialog("Rinomina oggetto", [{ name: "name", label: "Nome", value: currentName, maxLength: 120 }], "Rinomina");
|
||
if (!result) return;
|
||
const requestedName = result.name;
|
||
const name = requestedName.trim().slice(0, 120);
|
||
if (!name) {
|
||
setInfo("Nome non modificato: inserisci un testo.");
|
||
return;
|
||
}
|
||
rememberMapChange();
|
||
const properties = record.layer.feature.properties;
|
||
properties.name = name;
|
||
properties._lmeDisplayName = name;
|
||
record.layer.lmeManagerLabel = record.categoryKey === "drawings" && record.layer instanceof L.Marker
|
||
? `POI: ${name}`
|
||
: null;
|
||
updateStoredFeaturePopup(record.layer);
|
||
refreshImportedMeasurements();
|
||
refreshDrawingMeasurements();
|
||
refreshPermanentLabels();
|
||
refreshLayerManager();
|
||
finishMapChange();
|
||
setInfo(`Oggetto rinominato: ${name}.`);
|
||
}
|
||
|
||
function managedCategoryName(category, records) {
|
||
const storedName = lmeState.managerCategoryNames[category.key];
|
||
const featureName = records
|
||
.map(record => record.layer?.feature?.properties?._lmeCategoryName)
|
||
.find(Boolean);
|
||
const name = String(storedName || featureName || category.label).trim();
|
||
if (name !== category.label) {
|
||
lmeState.managerCategoryNames[category.key] = name;
|
||
}
|
||
return name;
|
||
}
|
||
|
||
function managedCategoryCanRename(category) {
|
||
return ["drawings", "imported", "importedSheets"].includes(category.key);
|
||
}
|
||
|
||
async function renameManagedCategory(category, records) {
|
||
const currentName = managedCategoryName(category, records);
|
||
const result = await showFormDialog("Rinomina gruppo", [{ name: "name", label: "Nome gruppo", value: currentName, maxLength: 120 }], "Rinomina");
|
||
if (!result) return;
|
||
const requestedName = result.name;
|
||
const name = requestedName.trim().slice(0, 120);
|
||
if (!name) {
|
||
setInfo("Nome non modificato: inserisci un testo.");
|
||
return;
|
||
}
|
||
rememberMapChange();
|
||
lmeState.managerCategoryNames[category.key] = name;
|
||
records.forEach(record => {
|
||
if (record.layer?.feature?.properties) {
|
||
record.layer.feature.properties._lmeCategoryName = name;
|
||
}
|
||
});
|
||
refreshLayerManager();
|
||
finishMapChange();
|
||
setInfo(`Gruppo rinominato: ${name}.`);
|
||
}
|
||
|
||
function managedBlockName(source, records) {
|
||
return String(records
|
||
.map(record => record.layer?.feature?.properties?._lmeGroupName)
|
||
.find(Boolean) || source);
|
||
}
|
||
|
||
async function renameManagedBlock(category, source, records) {
|
||
const currentName = managedBlockName(source, records);
|
||
const result = await showFormDialog("Rinomina gruppo", [{ name: "name", label: "Nome gruppo", value: currentName, maxLength: 120 }], "Rinomina");
|
||
if (!result) return;
|
||
const requestedName = result.name;
|
||
const name = requestedName.trim().slice(0, 120);
|
||
if (!name) {
|
||
setInfo("Nome non modificato: inserisci un testo.");
|
||
return;
|
||
}
|
||
rememberMapChange();
|
||
records.forEach(record => {
|
||
if (record.layer?.feature?.properties) {
|
||
record.layer.feature.properties._lmeGroupName = name;
|
||
}
|
||
});
|
||
refreshLayerManager();
|
||
finishMapChange();
|
||
setInfo(`Gruppo rinominato: ${name}.`);
|
||
}
|
||
|
||
function managedCategoryCanRemove(category, records) {
|
||
return records.length > 0
|
||
&& ["drawings", "imported", "importedSheets", "selectedParcels", "gpsParcels", "fiducials"].includes(category.key);
|
||
}
|
||
|
||
function managedObjectCanRemove(record) {
|
||
return ["drawings", "imported", "importedSheets", "selectedParcels", "gpsParcels", "fiducials"].includes(record.categoryKey);
|
||
}
|
||
|
||
function managedCategoryCanMove(categoryKey) {
|
||
return ["drawings", "imported", "importedSheets", "selectedParcels"].includes(categoryKey);
|
||
}
|
||
|
||
function layerManagerRowActions(renameButton, moveButton, removeButton) {
|
||
if (!renameButton && !moveButton && !removeButton) {
|
||
return "";
|
||
}
|
||
return `<span class="layer-row-actions">${renameButton}${moveButton}${removeButton}</span>`;
|
||
}
|
||
|
||
const layerManagerPalettes = [
|
||
{ surface: "#f0fdf4", parent: "#dcfce7", border: "#86efac", accent: "#166534" },
|
||
{ surface: "#eff6ff", parent: "#dbeafe", border: "#93c5fd", accent: "#1d4ed8" },
|
||
{ surface: "#fffbeb", parent: "#fef3c7", border: "#fcd34d", accent: "#92400e" },
|
||
{ surface: "#fff1f2", parent: "#ffe4e6", border: "#fda4af", accent: "#be123c" },
|
||
{ surface: "#f5f3ff", parent: "#ede9fe", border: "#c4b5fd", accent: "#6d28d9" },
|
||
{ surface: "#ecfeff", parent: "#cffafe", border: "#67e8f9", accent: "#0e7490" },
|
||
{ surface: "#f7fee7", parent: "#ecfccb", border: "#bef264", accent: "#3f6212" }
|
||
];
|
||
|
||
function applyLayerManagerPalette(row, key) {
|
||
const hash = [...String(key)].reduce((value, character) =>
|
||
((value * 31) + character.charCodeAt(0)) >>> 0, 0);
|
||
const palette = layerManagerPalettes[hash % layerManagerPalettes.length];
|
||
row.style.setProperty("--layer-group-surface", palette.surface);
|
||
row.style.setProperty("--layer-group-parent", palette.parent);
|
||
row.style.setProperty("--layer-group-border", palette.border);
|
||
row.style.setProperty("--layer-group-accent", palette.accent);
|
||
}
|
||
|
||
function clearManagedObjectState(id) {
|
||
lmeState.managerHiddenObjects.delete(id);
|
||
lmeState.managerHiddenObjectIds.delete(id);
|
||
lmeState.managerHiddenMeasurementSummaryIds.delete(id);
|
||
lmeState.managerHiddenMeasurementSideIds.delete(id);
|
||
Object.keys(lmeState.managerObjectOrder).forEach(categoryKey => {
|
||
lmeState.managerObjectOrder[categoryKey] = lmeState.managerObjectOrder[categoryKey]
|
||
.filter(objectId => objectId !== id);
|
||
});
|
||
}
|
||
|
||
function removeManagedRecords(records, subject) {
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||
if (!records.length) {
|
||
return;
|
||
}
|
||
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) {
|
||
setInfo("Operazione annullata: sblocca prima tutti gli oggetti coinvolti.");
|
||
return;
|
||
}
|
||
const message = records.length === 1
|
||
? `Rimuovere ${subject} dal progetto? Non verra incluso nel prossimo salvataggio.`
|
||
: `Rimuovere ${subject} e tutti i suoi ${records.length} oggetti dal progetto? Non verranno inclusi nel prossimo salvataggio.`;
|
||
if (!window.confirm(message)) {
|
||
return;
|
||
}
|
||
rememberMapChange();
|
||
lmeState.trash.push({
|
||
removedAt: new Date().toISOString(),
|
||
subject,
|
||
items: records.map(record => ({
|
||
categoryKey: record.categoryKey,
|
||
source: record.source,
|
||
feature: record.layer.toGeoJSON()
|
||
}))
|
||
});
|
||
if (lmeState.trash.length > 30) lmeState.trash.shift();
|
||
const removedIds = new Set(records.map(record => record.id));
|
||
const categoryKeys = new Set(records.map(record => record.categoryKey));
|
||
records.forEach(record => {
|
||
if (record.parent?.hasLayer?.(record.layer)) {
|
||
record.parent.removeLayer(record.layer);
|
||
}
|
||
clearManagedObjectState(record.id);
|
||
});
|
||
categoryKeys.forEach(categoryKey => {
|
||
const order = lmeState.managerObjectOrder[categoryKey] || [];
|
||
lmeState.managerObjectOrder[categoryKey] = order.filter(id => !removedIds.has(id));
|
||
const category = layerManagerCategories().find(item => item.key === categoryKey);
|
||
if (category && managedRecordsForCategory(category).length === 0) {
|
||
lmeState.managerRemovedCategories.add(categoryKey);
|
||
delete lmeState.managerCategoryNames[categoryKey];
|
||
}
|
||
});
|
||
clearManagerHighlight();
|
||
refreshImportedMeasurements();
|
||
refreshDrawingMeasurements();
|
||
updateGpsInsideState();
|
||
refreshLayerManager();
|
||
finishMapChange(`Rimozione: ${subject}`);
|
||
setInfo(`${records.length === 1 ? "Oggetto rimosso" : `${records.length} oggetti rimossi`} dal progetto.`);
|
||
}
|
||
|
||
function managedMoveDestinations() {
|
||
return layerManagerCategories()
|
||
.filter(category => ["drawings", "imported"].includes(category.key))
|
||
.map(category => {
|
||
const records = managedRecordsForCategory(category);
|
||
const groups = new Map();
|
||
records.forEach(record => {
|
||
if (!groups.has(record.source)) {
|
||
groups.set(record.source, managedBlockName(record.source, [record]));
|
||
}
|
||
});
|
||
if (!groups.size) {
|
||
groups.set("Progetto corrente", "Gruppo principale");
|
||
}
|
||
return {
|
||
key: category.key,
|
||
name: managedCategoryName(category, records),
|
||
groups: [...groups].map(([source, name]) => ({ source, name }))
|
||
};
|
||
});
|
||
}
|
||
|
||
function showMoveManagedRecordsDialog(records, subject) {
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||
if (!records.length || records.some(record => !managedCategoryCanMove(record.categoryKey))) {
|
||
setInfo("Questo oggetto o gruppo non puo essere spostato.");
|
||
return;
|
||
}
|
||
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) {
|
||
setInfo("Sblocca prima tutti gli oggetti da spostare.");
|
||
return;
|
||
}
|
||
const destinations = managedMoveDestinations();
|
||
const categoryOptions = destinations.map(destination =>
|
||
`<option value="${escapeHtml(destination.key)}">${escapeHtml(destination.name)}</option>`).join("");
|
||
const dialog = showAppDialog("Sposta nel gruppo", `<form class="lme-edit-form move-group-form"><p>Cambia l'appartenenza di ${escapeHtml(subject)} senza modificarne la posizione sulla mappa.</p><label>Categoria<select name="category">${categoryOptions}</select></label><label>Gruppo esistente<select name="group"></select></label><label>Oppure crea un nuovo gruppo<input name="newGroup" type="text" maxlength="120" placeholder="Nome del nuovo gruppo"></label><div class="dialog-command-row"><button type="button" data-cancel>Annulla</button><button type="submit" class="primary-command">Sposta nel gruppo</button></div></form>`);
|
||
const form = dialog.querySelector("form");
|
||
const categorySelect = form.elements.category;
|
||
const groupSelect = form.elements.group;
|
||
const newGroupInput = form.elements.newGroup;
|
||
const sourceCategory = records.every(record => record.categoryKey === records[0].categoryKey)
|
||
? records[0].categoryKey
|
||
: "drawings";
|
||
const currentCategory = destinations.some(destination => destination.key === sourceCategory)
|
||
? sourceCategory
|
||
: "imported";
|
||
categorySelect.value = currentCategory;
|
||
const refreshGroups = () => {
|
||
const destination = destinations.find(item => item.key === categorySelect.value);
|
||
groupSelect.innerHTML = (destination?.groups || []).map(group =>
|
||
`<option value="${escapeHtml(group.source)}">${escapeHtml(group.name)}</option>`).join("");
|
||
const currentSource = records.every(record => record.source === records[0].source)
|
||
? records[0].source
|
||
: "";
|
||
if (destination?.key === currentCategory && currentSource
|
||
&& destination.groups.some(group => group.source === currentSource)) {
|
||
groupSelect.value = currentSource;
|
||
}
|
||
};
|
||
categorySelect.addEventListener("change", refreshGroups);
|
||
refreshGroups();
|
||
dialog.querySelector("[data-cancel]").addEventListener("click", () => dialog.remove());
|
||
form.addEventListener("submit", event => {
|
||
event.preventDefault();
|
||
const destination = destinations.find(item => item.key === categorySelect.value);
|
||
if (!destination) return;
|
||
const requestedName = newGroupInput.value.trim().slice(0, 120);
|
||
const existingByName = requestedName
|
||
? destination.groups.find(group => group.name.toLocaleLowerCase("it") === requestedName.toLocaleLowerCase("it"))
|
||
: null;
|
||
const selectedGroup = destination.groups.find(group => group.source === groupSelect.value);
|
||
const target = existingByName || (requestedName
|
||
? { source: `Gruppo ${requestedName}`, name: requestedName }
|
||
: selectedGroup);
|
||
if (!target) {
|
||
setInfo("Scegli un gruppo di destinazione o inserisci il nome di un nuovo gruppo.");
|
||
return;
|
||
}
|
||
dialog.remove();
|
||
moveManagedRecordsToGroup(records, destination.key, target.source, target.name);
|
||
});
|
||
}
|
||
|
||
function moveManagedRecordsToGroup(records, targetCategory, targetSource, targetGroupName) {
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||
const movable = records.filter(record => managedCategoryCanMove(record.categoryKey));
|
||
if (!movable.length || !["drawings", "imported"].includes(targetCategory)) {
|
||
setInfo("Questo gruppo non puo essere spostato.");
|
||
return;
|
||
}
|
||
if (movable.some(record => record.layer?.feature?.properties?._lmeLocked)) {
|
||
setInfo("Sblocca il gruppo prima di spostarlo.");
|
||
return;
|
||
}
|
||
const additionalDrawings = targetCategory === "drawings"
|
||
? movable.filter(record => record.categoryKey !== "drawings").length
|
||
: 0;
|
||
if (!canAddDrawingObjects(additionalDrawings)) return;
|
||
rememberMapChange();
|
||
const features = movable.map(record => {
|
||
const feature = record.layer.toGeoJSON();
|
||
feature.properties = {
|
||
...(feature.properties || {}),
|
||
_lmeSource: targetSource,
|
||
_lmeGroupName: targetGroupName
|
||
};
|
||
delete feature.properties._lmeManagerId;
|
||
return feature;
|
||
});
|
||
movable.forEach(record => {
|
||
record.parent?.removeLayer?.(record.layer);
|
||
clearManagedObjectState(record.id);
|
||
lmeState.managerSelectedObjectIds.delete(record.id);
|
||
});
|
||
if (targetCategory === "drawings") {
|
||
features.forEach(feature => addDrawingFeature(feature));
|
||
} else {
|
||
addImportedFeatures({ type: "FeatureCollection", features });
|
||
}
|
||
refreshImportedMeasurements();
|
||
refreshDrawingMeasurements();
|
||
refreshLayerManager();
|
||
const categoryName = targetCategory === "drawings" ? "Disegni e POI" : "Progetti e dati importati";
|
||
finishMapChange(`Spostati ${features.length} oggetti in ${categoryName} > ${targetGroupName}.`);
|
||
setInfo(`${features.length} oggetti spostati in ${categoryName} > ${targetGroupName}.`);
|
||
}
|
||
|
||
function managedObjectDetails(layer) {
|
||
const details = [];
|
||
const properties = layer?.feature?.properties || {};
|
||
if (properties.name || properties.Name) {
|
||
details.push(`Nome: ${properties.name || properties.Name}`);
|
||
}
|
||
if (layer instanceof L.Circle) {
|
||
const radius = layer.getRadius();
|
||
details.push(`Raggio: ${formatDistance(radius)}`);
|
||
details.push(`Superficie: ${formatArea(Math.PI * radius * radius)}`);
|
||
details.push(`Circonferenza: ${formatDistance(2 * Math.PI * radius)}`);
|
||
} else if (layer instanceof L.Polygon) {
|
||
polygonLatLngGroups(layer.getLatLngs()).forEach((polygon, polygonIndex) => {
|
||
const outer = polygon[0] || [];
|
||
const area = polygon.reduce((sum, ring, ringIndex) => {
|
||
const ringArea = Math.abs(L.GeometryUtil.geodesicArea(ring));
|
||
return sum + (ringIndex === 0 ? ringArea : -ringArea);
|
||
}, 0);
|
||
details.push(`${polygonIndex ? `Area ${polygonIndex + 1}` : "Superficie"}: ${formatArea(Math.abs(area))}`);
|
||
details.push(`${polygonIndex ? `Perimetro ${polygonIndex + 1}` : "Perimetro"}: ${formatDistance(latLngPathLength(outer, true))}`);
|
||
outer.forEach((point, index) => {
|
||
const next = outer[(index + 1) % outer.length];
|
||
if (next && !point.equals(next)) {
|
||
details.push(`Lato ${index + 1}: ${formatDistance(lmeState.map.distance(point, next))}`);
|
||
}
|
||
});
|
||
});
|
||
} else if (layer instanceof L.Polyline) {
|
||
const lines = lineLatLngGroups(layer.getLatLngs());
|
||
details.push(`Lunghezza: ${formatDistance(lines.reduce((sum, line) => sum + latLngPathLength(line, false), 0))}`);
|
||
} else if (typeof layer.getLatLng === "function") {
|
||
const point = layer.getLatLng();
|
||
details.push(`Coordinate: ${point.lat.toFixed(6)}, ${point.lng.toFixed(6)}`);
|
||
}
|
||
return details;
|
||
}
|
||
|
||
function refreshLayerManager() {
|
||
updateFiducialDownloadButton();
|
||
const container = document.getElementById("layerManagerTree");
|
||
if (!container || !lmeState.map) {
|
||
return;
|
||
}
|
||
captureLayerManagerExpansion(container);
|
||
const scrollHost = container.closest(".map-layer-panel");
|
||
const scrollTop = scrollHost?.scrollTop || 0;
|
||
applyStoredLayerManagerVisibility();
|
||
container.innerHTML = "";
|
||
lmeState.managerRecords = new Map();
|
||
container.appendChild(renderLayerSelectionToolbar());
|
||
const categoriesByKey = new Map(layerManagerCategories().map(category => [category.key, category]));
|
||
lmeState.layerOrder.forEach(categoryKey => {
|
||
const category = categoriesByKey.get(categoryKey);
|
||
if (!category) {
|
||
return;
|
||
}
|
||
const records = managedRecordsForCategory(category);
|
||
if (records.length) {
|
||
lmeState.managerRemovedCategories.delete(category.key);
|
||
} else if (lmeState.managerRemovedCategories.has(category.key)) {
|
||
return;
|
||
}
|
||
const savedOrder = lmeState.managerObjectOrder[category.key] || [];
|
||
const positions = new Map(savedOrder.map((id, index) => [id, index]));
|
||
records.sort((left, right) =>
|
||
(positions.get(left.id) ?? Number.MAX_SAFE_INTEGER) - (positions.get(right.id) ?? Number.MAX_SAFE_INTEGER));
|
||
records.forEach(record => lmeState.managerRecords.set(record.id, record));
|
||
const rendered = renderLayerManagerCategory(category, records);
|
||
const search = lmeState.managerSearchText.trim().toLocaleLowerCase("it");
|
||
if (search && !records.some((record, index) => `${managedObjectLabel(record, index)} ${record.source}`.toLocaleLowerCase("it").includes(search))
|
||
&& !managedCategoryName(category, records).toLocaleLowerCase("it").includes(search)) {
|
||
rendered.hidden = true;
|
||
}
|
||
container.appendChild(rendered);
|
||
});
|
||
applyManagedVisualOrder(container);
|
||
bindLayerManagerDrag(container);
|
||
bindLayerManagerMoveButtons(container);
|
||
if (lmeState.managerHighlightedObjectId) {
|
||
window.requestAnimationFrame(() => revealManagedRecordInLayerManager(lmeState.managerHighlightedObjectId));
|
||
}
|
||
if (scrollHost) {
|
||
scrollHost.scrollTop = scrollTop;
|
||
window.requestAnimationFrame(() => {
|
||
scrollHost.scrollTop = scrollTop;
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderLayerManagerCategory(category, records) {
|
||
const group = document.createElement("details");
|
||
group.className = "layer-manager-group";
|
||
group.classList.toggle("layer-manager-selection-path", records.some(record =>
|
||
record.id === lmeState.managerHighlightedObjectId || lmeState.managerSelectedObjectIds.has(record.id)));
|
||
group.dataset.dragType = "category";
|
||
group.dataset.layerDepth = "0";
|
||
group.dataset.categoryKey = category.key;
|
||
applyLayerManagerPalette(group, `category:${category.key}`);
|
||
bindLayerManagerExpansion(group, `category:${category.key}`);
|
||
const categoryName = managedCategoryName(category, records);
|
||
const renameButton = managedCategoryCanRename(category)
|
||
? `<button type="button" class="layer-rename-button" aria-label="Rinomina gruppo" title="Rinomina">Rinomina</button>`
|
||
: "";
|
||
const removeButton = managedCategoryCanRemove(category, records)
|
||
? `<button type="button" class="layer-remove-button" aria-label="Rimuovi gruppo dal progetto" title="Rimuovi dal progetto">Rimuovi</button>`
|
||
: "";
|
||
const moveButton = records.length && managedCategoryCanMove(category.key)
|
||
? `<button type="button" class="layer-membership-button" aria-label="Sposta gruppo in un altro gruppo" title="Sposta nel gruppo">Sposta nel gruppo</button>`
|
||
: "";
|
||
const rowActions = layerManagerRowActions(renameButton, moveButton, removeButton);
|
||
group.classList.toggle("layer-manager-with-actions", Boolean(rowActions));
|
||
const visibleCount = records.filter(record => !lmeState.managerHiddenObjectIds.has(record.id)).length;
|
||
const categoryVisible = !lmeState.managerHiddenCategories.has(category.key)
|
||
&& (!category.roots.length || category.roots.some(layer => lmeState.map.hasLayer(layer)));
|
||
const visible = categoryVisible;
|
||
const categoryLocked = records.length > 0 && records.every(record => Boolean(record.layer?.feature?.properties?._lmeLocked));
|
||
group.innerHTML = `<summary>${layerExpandButton(group.open, categoryName)}<button type="button" class="layer-drag-handle" aria-label="Trascina gruppo">☰</button><input type="checkbox" ${visible ? "checked" : ""} aria-label="Mostra ${escapeHtml(categoryName)}"><span class="layer-manager-title">${escapeHtml(categoryName)}</span><button type="button" class="layer-inline-lock ${categoryLocked ? "is-locked" : ""}" aria-label="${categoryLocked ? "Sblocca" : "Blocca"} gruppo" title="${categoryLocked ? "Sblocca" : "Blocca"}">${categoryLocked ? "🔒" : "🔓"}</button>${rowActions}${layerManagerMoveButtons("gruppo")}<span class="layer-manager-count">${records.length}</span></summary><div class="layer-manager-children"></div>`;
|
||
bindLayerManagerToggleButton(group);
|
||
const checkbox = group.querySelector("input");
|
||
checkbox.indeterminate = categoryVisible && records.length > 0 && visibleCount < records.length;
|
||
checkbox.addEventListener("click", event => event.stopPropagation());
|
||
checkbox.addEventListener("change", () => setManagedCategoryVisible(category, checkbox.checked));
|
||
group.querySelector(".layer-inline-lock")?.addEventListener("click", event => {
|
||
event.preventDefault(); event.stopPropagation();
|
||
updateManagedProperties(records, properties => { properties._lmeLocked = !categoryLocked; });
|
||
});
|
||
group.querySelector(".layer-rename-button")?.addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
renameManagedCategory(category, records);
|
||
});
|
||
group.querySelector(".layer-remove-button")?.addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
removeManagedRecords(records, `il gruppo '${categoryName}'`);
|
||
});
|
||
group.querySelector(".layer-membership-button")?.addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
showMoveManagedRecordsDialog(records, `il gruppo '${categoryName}'`);
|
||
});
|
||
bindManagedParentHighlight(group, `category:${category.key}`, records);
|
||
const children = group.querySelector(".layer-manager-children");
|
||
if (records.length) children.appendChild(renderManagedGroupTools(records));
|
||
if (category.blocks) {
|
||
const blocks = new Map();
|
||
records.forEach(record => {
|
||
const list = blocks.get(record.source) || [];
|
||
list.push(record);
|
||
blocks.set(record.source, list);
|
||
});
|
||
blocks.forEach((blockRecords, source) => children.appendChild(renderLayerManagerBlock(category, source, blockRecords)));
|
||
} else {
|
||
records.forEach((record, index) => children.appendChild(renderLayerManagerObject(record, index)));
|
||
}
|
||
return group;
|
||
}
|
||
|
||
function renderLayerManagerBlock(category, source, records) {
|
||
const block = document.createElement("details");
|
||
block.className = "layer-manager-block";
|
||
block.classList.toggle("layer-manager-selection-path", records.some(record =>
|
||
record.id === lmeState.managerHighlightedObjectId || lmeState.managerSelectedObjectIds.has(record.id)));
|
||
block.dataset.dragType = "block";
|
||
block.dataset.layerDepth = "1";
|
||
block.dataset.categoryKey = category.key;
|
||
block.dataset.blockKey = source;
|
||
applyLayerManagerPalette(block, `block:${category.key}:${source}`);
|
||
bindLayerManagerExpansion(block, `block:${category.key}:${source}`);
|
||
const visibleCount = records.filter(record => !lmeState.managerHiddenObjectIds.has(record.id)).length;
|
||
const visible = visibleCount === records.length;
|
||
const blockName = managedBlockName(source, records);
|
||
const rowActions = layerManagerRowActions(
|
||
`<button type="button" class="layer-rename-button" aria-label="Rinomina gruppo" title="Rinomina">Rinomina</button>`,
|
||
managedCategoryCanMove(category.key)
|
||
? `<button type="button" class="layer-membership-button" aria-label="Sposta gruppo in un altro gruppo" title="Sposta nel gruppo">Sposta nel gruppo</button>`
|
||
: "",
|
||
managedObjectCanRemove(records[0])
|
||
? `<button type="button" class="layer-remove-button" aria-label="Rimuovi gruppo dal progetto" title="Rimuovi dal progetto">Rimuovi</button>`
|
||
: "");
|
||
block.classList.add("layer-manager-with-actions");
|
||
const blockLocked = records.length > 0 && records.every(record => Boolean(record.layer?.feature?.properties?._lmeLocked));
|
||
block.innerHTML = `<summary>${layerExpandButton(block.open, blockName)}<button type="button" class="layer-drag-handle" aria-label="Trascina blocco">☰</button><input type="checkbox" ${visible ? "checked" : ""} aria-label="Mostra ${escapeHtml(blockName)}"><span class="layer-manager-title">${escapeHtml(blockName)}</span><button type="button" class="layer-inline-lock ${blockLocked ? "is-locked" : ""}" aria-label="${blockLocked ? "Sblocca" : "Blocca"} gruppo" title="${blockLocked ? "Sblocca" : "Blocca"}">${blockLocked ? "🔒" : "🔓"}</button>${rowActions}${layerManagerMoveButtons("blocco")}<span class="layer-manager-count">${records.length}</span></summary><div class="layer-manager-children"></div>`;
|
||
bindLayerManagerToggleButton(block);
|
||
const checkbox = block.querySelector("input");
|
||
checkbox.indeterminate = visibleCount > 0 && !visible;
|
||
checkbox.addEventListener("click", event => event.stopPropagation());
|
||
checkbox.addEventListener("change", () => {
|
||
records.forEach(record => setManagedObjectVisible(record, checkbox.checked, false));
|
||
refreshLayerManager();
|
||
});
|
||
block.querySelector(".layer-inline-lock")?.addEventListener("click", event => {
|
||
event.preventDefault(); event.stopPropagation();
|
||
updateManagedProperties(records, properties => { properties._lmeLocked = !blockLocked; });
|
||
});
|
||
block.querySelector(".layer-rename-button").addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
renameManagedBlock(category, source, records);
|
||
});
|
||
block.querySelector(".layer-remove-button")?.addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
removeManagedRecords(records, `il gruppo '${blockName}'`);
|
||
});
|
||
block.querySelector(".layer-membership-button")?.addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
showMoveManagedRecordsDialog(records, `il gruppo '${blockName}'`);
|
||
});
|
||
bindManagedParentHighlight(block, `block:${category.key}:${source}`, records);
|
||
const children = block.querySelector(".layer-manager-children");
|
||
children.appendChild(renderManagedGroupTools(records));
|
||
records.forEach((record, index) => children.appendChild(renderLayerManagerObject(record, index)));
|
||
return block;
|
||
}
|
||
|
||
function renderLayerManagerObject(record, index) {
|
||
const object = document.createElement("details");
|
||
object.className = "layer-manager-object";
|
||
if (record.id === lmeState.managerHighlightedObjectId) {
|
||
object.classList.add("layer-manager-object-selected");
|
||
}
|
||
object.dataset.dragType = "object";
|
||
object.dataset.layerDepth = "2";
|
||
object.dataset.objectId = record.id;
|
||
bindLayerManagerExpansion(object, `object:${record.id}`);
|
||
const visible = !lmeState.managerHiddenObjectIds.has(record.id);
|
||
const renameButton = managedObjectCanRename(record)
|
||
? `<button type="button" class="layer-rename-button" aria-label="Rinomina oggetto" title="Rinomina">Rinomina</button>`
|
||
: "";
|
||
const removeButton = managedObjectCanRemove(record)
|
||
? `<button type="button" class="layer-remove-button" aria-label="Rimuovi oggetto dal progetto" title="Rimuovi dal progetto">Rimuovi</button>`
|
||
: "";
|
||
const moveButton = managedCategoryCanMove(record.categoryKey)
|
||
? `<button type="button" class="layer-membership-button" aria-label="Sposta oggetto in un altro gruppo" title="Sposta nel gruppo">Sposta nel gruppo</button>`
|
||
: "";
|
||
const rowActions = layerManagerRowActions(renameButton, moveButton, removeButton);
|
||
object.classList.toggle("layer-manager-with-actions", Boolean(rowActions));
|
||
const objectName = managedObjectLabel(record, index);
|
||
const locked = Boolean(record.layer?.feature?.properties?._lmeLocked);
|
||
object.innerHTML = `<summary>${layerExpandButton(object.open, objectName)}<button type="button" class="layer-drag-handle" aria-label="Trascina oggetto">☰</button><input type="checkbox" ${visible ? "checked" : ""} aria-label="Mostra oggetto"><span class="layer-manager-title">${escapeHtml(objectName)}</span><button type="button" class="layer-inline-lock ${locked ? "is-locked" : ""}" aria-label="${locked ? "Sblocca" : "Blocca"} oggetto" title="${locked ? "Sblocca" : "Blocca"}">${locked ? "🔒" : "🔓"}</button>${rowActions}${layerManagerMoveButtons("oggetto")}</summary><div class="layer-manager-details"></div>`;
|
||
bindLayerManagerToggleButton(object);
|
||
const checkbox = object.querySelector("input");
|
||
checkbox.addEventListener("click", event => event.stopPropagation());
|
||
checkbox.addEventListener("change", () => setManagedObjectVisible(record, checkbox.checked, true));
|
||
object.querySelector(".layer-rename-button")?.addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
renameManagedObject(record, index);
|
||
});
|
||
object.querySelector(".layer-remove-button")?.addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
removeManagedRecords([record], `'${objectName}'`);
|
||
});
|
||
object.querySelector(".layer-membership-button")?.addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
showMoveManagedRecordsDialog([record], `'${objectName}'`);
|
||
});
|
||
object.querySelector(".layer-inline-lock")?.addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
updateManagedProperties([record], value => { value._lmeLocked = !locked; });
|
||
});
|
||
object.querySelector(".layer-manager-title").addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
highlightManagedObject(record);
|
||
});
|
||
const details = object.querySelector(".layer-manager-details");
|
||
details.appendChild(renderLayerManagerVisibilityOption(
|
||
geometryVisibilityLabel(record.layer),
|
||
visible,
|
||
checked => setManagedObjectVisible(record, checked, true)));
|
||
if (supportsManagedMeasurements(record)) {
|
||
managedMeasurementKinds(record).forEach(kind => {
|
||
const measurementsVisible = managedMeasurementsGloballyVisible(record.categoryKey)
|
||
&& !managedMeasurementHiddenSet(kind.key).has(record.id);
|
||
details.appendChild(renderLayerManagerVisibilityOption(
|
||
kind.label,
|
||
measurementsVisible,
|
||
checked => setManagedMeasurementKindVisible(record, kind.key, checked)));
|
||
});
|
||
}
|
||
const rows = managedObjectDetails(record.layer);
|
||
const info = document.createElement("div");
|
||
info.className = "layer-manager-info";
|
||
info.innerHTML = rows.length
|
||
? rows.map(value => `<span>${escapeHtml(value)}</span>`).join("")
|
||
: "<span>Nessuna misura disponibile</span>";
|
||
details.appendChild(info);
|
||
details.appendChild(renderManagedObjectTools(record));
|
||
return object;
|
||
}
|
||
|
||
function renderLayerSelectionToolbar() {
|
||
const selectedCount = lmeState.managerSelectedObjectIds.size;
|
||
const toolbar = document.createElement("section");
|
||
toolbar.className = "layer-selection-toolbar";
|
||
toolbar.innerHTML = `<label>Cerca oggetti<input type="search" value="${escapeHtml(lmeState.managerSearchText)}" placeholder="Nome o gruppo"></label>
|
||
<div class="layer-selection-summary"><strong>${selectedCount} selezionati</strong><button type="button" data-action="clear">Deseleziona</button></div>
|
||
<div class="layer-batch-actions">
|
||
<button type="button" data-action="duplicate">Duplica</button><button type="button" data-action="move">Sposta geometria</button>
|
||
<button type="button" data-action="buffer">Buffer</button><button type="button" data-action="union">Unisci</button>
|
||
<button type="button" data-action="split">Dividi</button><button type="button" data-action="delete" class="danger-command">Rimuovi</button>
|
||
</div>`;
|
||
const search = toolbar.querySelector("input");
|
||
search.addEventListener("input", () => {
|
||
const selectionStart = search.selectionStart ?? search.value.length;
|
||
const selectionEnd = search.selectionEnd ?? selectionStart;
|
||
lmeState.managerSearchText = search.value;
|
||
refreshLayerManager();
|
||
const replacement = document.querySelector(".layer-selection-toolbar input");
|
||
if (replacement) {
|
||
replacement.focus({ preventScroll: true });
|
||
replacement.setSelectionRange(selectionStart, selectionEnd);
|
||
}
|
||
});
|
||
toolbar.querySelectorAll("button").forEach(button => button.addEventListener("click", () => {
|
||
const records = [...lmeState.managerSelectedObjectIds]
|
||
.map(id => lmeState.managerRecords?.get(id)).filter(Boolean);
|
||
if (button.dataset.action === "clear") {
|
||
lmeState.managerSelectedObjectIds.clear();
|
||
refreshLayerManager();
|
||
} else if (!records.length) {
|
||
setInfo("Seleziona almeno un oggetto dalle sue opzioni.");
|
||
} else if (button.dataset.action === "duplicate") duplicateManagedRecords(records);
|
||
else if (button.dataset.action === "move") moveManagedRecords(records);
|
||
else if (button.dataset.action === "buffer") bufferManagedRecords(records);
|
||
else if (button.dataset.action === "union") unionManagedRecords(records);
|
||
else if (button.dataset.action === "split") splitManagedRecords(records);
|
||
else if (button.dataset.action === "delete") removeManagedRecords(records, "gli oggetti selezionati");
|
||
}));
|
||
return toolbar;
|
||
}
|
||
|
||
function renderManagedGroupTools(records) {
|
||
const tools = document.createElement("div");
|
||
tools.className = "layer-group-tools";
|
||
const firstLayer = records[0]?.layer;
|
||
const properties = firstLayer?.feature?.properties || {};
|
||
const lineColor = normalizedColor(properties._lmeColor || firstLayer?.options?.color, "#16806a");
|
||
const fillColor = normalizedColor(properties._lmeFillColor || firstLayer?.options?.fillColor, lineColor);
|
||
tools.innerHTML = `<strong>Azioni gruppo</strong><span class="layer-group-totals">${escapeHtml(managedGroupTotals(records))}</span><div class="layer-style-controls">
|
||
${managedColorEditorMarkup("color", "Colore linea", lineColor)}
|
||
${managedColorEditorMarkup("fillColor", "Colore riempimento", fillColor)}
|
||
<label>Spessore<input type="range" data-field="weight" min="1" max="10" step="1" value="${Number(properties._lmeWeight) || Number(firstLayer?.options?.weight) || 3}"></label>
|
||
<label>Opacita<input type="range" data-field="opacity" min="0.1" max="1" step="0.1" value="${Number.isFinite(Number(properties._lmeOpacity)) ? Number(properties._lmeOpacity) : Number(firstLayer?.options?.opacity) || 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 applyCurrentStyle = () => {
|
||
applyManagedStyle(records, {
|
||
color: tools.querySelector("[data-color-picker='color']").value,
|
||
fillColor: tools.querySelector("[data-color-picker='fillColor']").value,
|
||
weight: Number(tools.querySelector("[data-field='weight']").value),
|
||
opacity: Number(tools.querySelector("[data-field='opacity']").value)
|
||
});
|
||
};
|
||
bindManagedColorEditors(tools, applyCurrentStyle);
|
||
tools.querySelectorAll("input[type='range']").forEach(input => input.addEventListener("change", applyCurrentStyle));
|
||
tools.querySelector("[data-group-action='select']").addEventListener("click", () => {
|
||
records.forEach(record => lmeState.managerSelectedObjectIds.add(record.id)); refreshLayerManager();
|
||
});
|
||
tools.querySelector("[data-group-action='lock']").addEventListener("click", () => {
|
||
const lock = !records.every(record => Boolean(record.layer?.feature?.properties?._lmeLocked));
|
||
updateManagedProperties(records, properties => { properties._lmeLocked = lock; });
|
||
setInfo(lock ? "Gruppo bloccato." : "Gruppo sbloccato.");
|
||
});
|
||
tools.querySelector("[data-group-action='labels']").addEventListener("click", () => {
|
||
const visible = !records.every(record => Boolean(record.layer?.feature?.properties?._lmeLabelVisible));
|
||
updateManagedProperties(records, properties => { properties._lmeLabelVisible = visible; });
|
||
});
|
||
return tools;
|
||
}
|
||
|
||
function managedGroupTotals(records) {
|
||
let length = 0; let area = 0;
|
||
records.forEach(record => {
|
||
const layer = record.layer;
|
||
if (layer instanceof L.Circle) { area += Math.PI * layer.getRadius() ** 2; length += 2 * Math.PI * layer.getRadius(); }
|
||
else if (layer instanceof L.Polygon) polygonLatLngGroups(layer.getLatLngs()).forEach(polygon => {
|
||
area += Math.abs(polygon.reduce((sum, ring, index) => sum + (index ? -1 : 1) * Math.abs(L.GeometryUtil.geodesicArea(ring)), 0));
|
||
length += latLngPathLength(polygon[0] || [], true);
|
||
});
|
||
else if (layer instanceof L.Polyline) length += lineLatLngGroups(layer.getLatLngs()).reduce((sum, line) => sum + latLngPathLength(line, false), 0);
|
||
});
|
||
const values = []; if (area) values.push(`Area ${formatArea(area)}`); if (length) values.push(`Sviluppo ${formatDistance(length)}`);
|
||
return values.join(" | ") || `${records.length} oggetti`;
|
||
}
|
||
|
||
function renderManagedObjectTools(record) {
|
||
const properties = record.layer.feature?.properties || (record.layer.feature = { type: "Feature", properties: {} }).properties;
|
||
const selected = lmeState.managerSelectedObjectIds.has(record.id);
|
||
const locked = Boolean(properties._lmeLocked);
|
||
const tools = document.createElement("section");
|
||
tools.className = "layer-object-tools";
|
||
const lineColor = normalizedColor(properties._lmeColor || record.layer?.options?.color, "#f97316");
|
||
const fillColor = normalizedColor(properties._lmeFillColor || record.layer?.options?.fillColor, lineColor);
|
||
tools.innerHTML = `<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">${managedColorEditorMarkup("color", "Colore linea", lineColor)}
|
||
${managedColorEditorMarkup("fillColor", "Colore riempimento", fillColor)}
|
||
<label>Spessore<input type="range" data-field="weight" min="1" max="10" step="1" value="${Number(properties._lmeWeight) || 3}"></label>
|
||
<label>Opacita<input type="range" data-field="opacity" min="0.1" max="1" step="0.1" value="${Number(properties._lmeOpacity) || 1}"></label>
|
||
<label>Riempimento %<input type="range" data-field="fillOpacity" min="0" max="0.8" step="0.05" value="${Number.isFinite(Number(properties._lmeFillOpacity)) ? Number(properties._lmeFillOpacity) : 0.2}"></label>
|
||
<label>Tratto<select data-field="dash"><option value="">Continuo</option><option value="8 6" ${properties._lmeDash === "8 6" ? "selected" : ""}>Tratteggiato</option><option value="2 6" ${properties._lmeDash === "2 6" ? "selected" : ""}>Puntinato</option></select></label></div>
|
||
<label>Note<textarea data-field="notes" rows="2" maxlength="1000" placeholder="Note tecniche">${escapeHtml(properties._lmeNotes || "")}</textarea></label>
|
||
<label class="layer-photo-picker">Foto<input type="file" data-field="photo" accept="image/*"></label>
|
||
${properties._lmePhoto ? `<img class="layer-photo-preview" src="${properties._lmePhoto}" alt="Foto allegata"><button type="button" data-action="remove-photo">Rimuovi foto</button>` : ""}
|
||
<div class="layer-compact-actions"><button type="button" data-action="attributes">Attributi</button><button type="button" data-action="duplicate">Duplica</button><button type="button" data-action="numeric">Coordinate</button><button type="button" data-action="inspect">WGS84 / UTM</button><button type="button" data-action="move">Sposta geometria</button><button type="button" data-action="buffer">Buffer</button></div>`;
|
||
tools.querySelector("[data-field='selected']").addEventListener("change", event => {
|
||
if (event.target.checked) lmeState.managerSelectedObjectIds.add(record.id);
|
||
else lmeState.managerSelectedObjectIds.delete(record.id);
|
||
refreshLayerManager();
|
||
});
|
||
tools.querySelector("[data-field='locked']").addEventListener("change", event => updateManagedProperties([record], value => { value._lmeLocked = event.target.checked; }));
|
||
tools.querySelector("[data-field='label']").addEventListener("change", event => updateManagedProperties([record], value => { value._lmeLabelVisible = event.target.checked; }));
|
||
const applyCurrentStyle = () => applyManagedStyle([record], {
|
||
color: tools.querySelector("[data-color-picker='color']").value,
|
||
fillColor: tools.querySelector("[data-color-picker='fillColor']").value,
|
||
weight: Number(tools.querySelector("[data-field='weight']").value),
|
||
opacity: Number(tools.querySelector("[data-field='opacity']").value),
|
||
fillOpacity: Number(tools.querySelector("[data-field='fillOpacity']").value),
|
||
dash: tools.querySelector("[data-field='dash']").value
|
||
});
|
||
bindManagedColorEditors(tools, applyCurrentStyle);
|
||
tools.querySelectorAll(".layer-style-controls input[type='range'],.layer-style-controls select").forEach(input => input.addEventListener("change", applyCurrentStyle));
|
||
tools.querySelector("[data-field='notes']").addEventListener("change", event => updateManagedProperties([record], value => { value._lmeNotes = event.target.value.trim(); }));
|
||
tools.querySelector("[data-field='photo']").addEventListener("change", async event => {
|
||
const file = event.target.files?.[0]; if (!file) return;
|
||
if (file.size > 2_500_000) { setInfo("Foto troppo grande: usa un file inferiore a 2,5 MB."); return; }
|
||
const dataUrl = await fileToDataUrl(file);
|
||
updateManagedProperties([record], value => { value._lmePhoto = dataUrl; });
|
||
});
|
||
tools.querySelector("[data-action='remove-photo']")?.addEventListener("click", () => updateManagedProperties([record], value => { delete value._lmePhoto; }));
|
||
tools.querySelector("[data-action='attributes']").addEventListener("click", () => editManagedAttributes(record));
|
||
tools.querySelector("[data-action='duplicate']").addEventListener("click", () => duplicateManagedRecords([record]));
|
||
tools.querySelector("[data-action='numeric']").addEventListener("click", () => numericEditManagedRecord(record));
|
||
tools.querySelector("[data-action='inspect']").addEventListener("click", () => showCoordinateInspectorForRecord(record));
|
||
tools.querySelector("[data-action='move']").addEventListener("click", () => moveManagedRecords([record]));
|
||
tools.querySelector("[data-action='buffer']").addEventListener("click", () => bufferManagedRecords([record]));
|
||
return tools;
|
||
}
|
||
|
||
function normalizedColor(value, fallback) {
|
||
return /^#[0-9a-f]{6}$/i.test(String(value || "")) ? value : fallback;
|
||
}
|
||
|
||
function managedColorEditorMarkup(field, label, color) {
|
||
const formats = managedColorFormats(color);
|
||
return `<section class="layer-color-editor" data-color-editor="${field}">
|
||
<header><strong>${escapeHtml(label)}</strong><input type="color" data-color-picker="${field}" value="${formats.hex}" aria-label="Seleziona ${escapeHtml(label.toLocaleLowerCase("it"))}"><button type="button" data-color-eyedropper title="Preleva un colore dallo schermo">Preleva</button></header>
|
||
<div class="layer-color-formats">
|
||
<label>HEX<input type="text" data-color-format="hex" value="${formats.hex}" maxlength="7" spellcheck="false"></label>
|
||
<label>RGB<input type="text" data-color-format="rgb" value="${formats.rgb}" spellcheck="false"></label>
|
||
<label>HSL<input type="text" data-color-format="hsl" value="${formats.hsl}" spellcheck="false"></label>
|
||
</div>
|
||
<button type="button" class="layer-color-copy" data-color-copy>Copia HEX</button>
|
||
</section>`;
|
||
}
|
||
|
||
function bindManagedColorEditors(container, onChanged) {
|
||
container.querySelectorAll("[data-color-editor]").forEach(editor => {
|
||
const picker = editor.querySelector("[data-color-picker]");
|
||
const formatInputs = [...editor.querySelectorAll("[data-color-format]")];
|
||
const sync = (color, notify) => {
|
||
const formats = managedColorFormats(color);
|
||
picker.value = formats.hex;
|
||
formatInputs.forEach(input => { input.value = formats[input.dataset.colorFormat]; });
|
||
if (notify) onChanged();
|
||
};
|
||
picker.addEventListener("change", () => sync(picker.value, true));
|
||
formatInputs.forEach(input => input.addEventListener("change", () => {
|
||
const parsed = parseManagedColor(input.value);
|
||
if (!parsed) {
|
||
sync(picker.value, false);
|
||
setInfo("Colore non valido. Usa HEX, rgb(r, g, b) oppure hsl(h, s%, l%).");
|
||
return;
|
||
}
|
||
sync(parsed, true);
|
||
}));
|
||
const eyedropper = editor.querySelector("[data-color-eyedropper]");
|
||
if (typeof window.EyeDropper !== "function") {
|
||
eyedropper.hidden = true;
|
||
} else {
|
||
eyedropper.addEventListener("click", async () => {
|
||
try {
|
||
const result = await new window.EyeDropper().open();
|
||
if (result?.sRGBHex) sync(result.sRGBHex, true);
|
||
} catch (error) {
|
||
if (error?.name !== "AbortError") setInfo("Contagocce non disponibile su questo dispositivo.");
|
||
}
|
||
});
|
||
}
|
||
editor.querySelector("[data-color-copy]").addEventListener("click", async () => {
|
||
const value = picker.value.toUpperCase();
|
||
try {
|
||
await navigator.clipboard.writeText(value);
|
||
setInfo(`Colore ${value} copiato. Puoi incollarlo nel campo HEX di un altro oggetto.`);
|
||
} catch {
|
||
const hexInput = editor.querySelector("[data-color-format='hex']");
|
||
hexInput.focus();
|
||
hexInput.select();
|
||
setInfo(`Selezionato ${value}: usa il comando Copia del dispositivo.`);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function parseManagedColor(value) {
|
||
const text = String(value || "").trim();
|
||
const shortHex = text.match(/^#([0-9a-f]{3})$/i);
|
||
if (shortHex) return `#${[...shortHex[1]].map(part => part + part).join("")}`.toUpperCase();
|
||
if (/^#[0-9a-f]{6}$/i.test(text)) return text.toUpperCase();
|
||
const rgb = text.match(/^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})(?:\s*[,/]\s*[\d.]+)?\s*\)$/i);
|
||
if (rgb) {
|
||
const values = rgb.slice(1, 4).map(Number);
|
||
if (values.every(channel => channel >= 0 && channel <= 255)) return managedRgbToHex(...values);
|
||
}
|
||
const hsl = text.match(/^hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[, ]\s*([\d.]+)%\s*[, ]\s*([\d.]+)%(?:\s*[,/]\s*[\d.]+)?\s*\)$/i);
|
||
if (hsl) {
|
||
const saturation = Number(hsl[2]); const lightness = Number(hsl[3]);
|
||
if (saturation >= 0 && saturation <= 100 && lightness >= 0 && lightness <= 100) {
|
||
return managedRgbToHex(...managedHslToRgb(Number(hsl[1]), saturation, lightness));
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function managedColorFormats(color) {
|
||
const hex = normalizedColor(color, "#000000").toUpperCase();
|
||
const [red, green, blue] = [1, 3, 5].map(index => parseInt(hex.slice(index, index + 2), 16));
|
||
const redUnit = red / 255; const greenUnit = green / 255; const blueUnit = blue / 255;
|
||
const max = Math.max(redUnit, greenUnit, blueUnit); const min = Math.min(redUnit, greenUnit, blueUnit);
|
||
const lightness = (max + min) / 2; const delta = max - min;
|
||
let hue = 0; let saturation = 0;
|
||
if (delta) {
|
||
saturation = delta / (1 - Math.abs(2 * lightness - 1));
|
||
if (max === redUnit) hue = 60 * (((greenUnit - blueUnit) / delta) % 6);
|
||
else if (max === greenUnit) hue = 60 * ((blueUnit - redUnit) / delta + 2);
|
||
else hue = 60 * ((redUnit - greenUnit) / delta + 4);
|
||
}
|
||
if (hue < 0) hue += 360;
|
||
return {
|
||
hex,
|
||
rgb: `rgb(${red}, ${green}, ${blue})`,
|
||
hsl: `hsl(${managedColorDecimal(hue)}, ${managedColorDecimal(saturation * 100)}%, ${managedColorDecimal(lightness * 100)}%)`
|
||
};
|
||
}
|
||
|
||
function managedColorDecimal(value) {
|
||
return String(Number(value.toFixed(2)));
|
||
}
|
||
|
||
function managedRgbToHex(red, green, blue) {
|
||
return `#${[red, green, blue].map(channel => Math.round(channel).toString(16).padStart(2, "0")).join("")}`.toUpperCase();
|
||
}
|
||
|
||
function managedHslToRgb(hue, saturation, lightness) {
|
||
const normalizedHue = ((hue % 360) + 360) % 360;
|
||
const saturationUnit = saturation / 100; const lightnessUnit = lightness / 100;
|
||
const chroma = (1 - Math.abs(2 * lightnessUnit - 1)) * saturationUnit;
|
||
const section = normalizedHue / 60; const intermediate = chroma * (1 - Math.abs(section % 2 - 1));
|
||
const values = section < 1 ? [chroma, intermediate, 0]
|
||
: section < 2 ? [intermediate, chroma, 0]
|
||
: section < 3 ? [0, chroma, intermediate]
|
||
: section < 4 ? [0, intermediate, chroma]
|
||
: section < 5 ? [intermediate, 0, chroma]
|
||
: [chroma, 0, intermediate];
|
||
const offset = lightnessUnit - chroma / 2;
|
||
return values.map(channel => Math.round((channel + offset) * 255));
|
||
}
|
||
|
||
function editableManagedMetadataEntries(record) {
|
||
const properties = record?.layer?.feature?.properties || {};
|
||
const reserved = new Set(["name", "Name", "label", "LABEL"]);
|
||
return Object.entries(properties)
|
||
.filter(([key, value]) => !key.startsWith("_lme") && !reserved.has(key) && ["string", "number", "boolean"].includes(typeof value));
|
||
}
|
||
|
||
function editManagedAttributes(record) {
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||
const reserved = new Set(["name", "Name", "label", "LABEL"]);
|
||
const entries = editableManagedMetadataEntries(record);
|
||
const sourceRecords = layerManagerCategories()
|
||
.flatMap(category => managedRecordsForCategory(category))
|
||
.filter(item => item.id !== record.id);
|
||
const sourceOptions = sourceRecords.map((item, index) =>
|
||
`<option value="${escapeHtml(item.id)}">${escapeHtml(managedObjectLabel(item, index))} - ${escapeHtml(item.source)}</option>`).join("");
|
||
const dialog = showAppDialog("Metadati oggetto", `<div class="attribute-editor"><p>Ogni metadato viene salvato nel progetto e negli export GeoJSON, KML e KMZ.</p>
|
||
<section class="attribute-copy-panel"><strong>Copia da un altro oggetto</strong><label>Oggetto sorgente<select data-copy-source ${sourceRecords.length ? "" : "disabled"}><option value="">Seleziona un oggetto</option>${sourceOptions}</select></label><label class="toggle-line"><input type="checkbox" data-copy-values checked><span>Importa anche i valori</span></label><small>Disattiva il flag per importare soltanto i nomi dei metadati con valore vuoto.</small><button type="button" data-copy-attributes ${sourceRecords.length ? "" : "disabled"}>Copia da</button></section>
|
||
<div class="attribute-editor-rows"></div><button type="button" data-add-attribute>+ Aggiungi metadato</button><div class="dialog-command-row"><button type="button" data-cancel>Annulla</button><button type="button" class="primary-command" data-save-attributes>Salva metadati</button></div></div>`);
|
||
const rows = dialog.querySelector(".attribute-editor-rows");
|
||
const addRow = (key = "", value = "") => {
|
||
const row = document.createElement("div");
|
||
row.className = "attribute-editor-row";
|
||
row.innerHTML = `<label>Nome<input type="text" maxlength="60" value="${escapeHtml(key)}" placeholder="Es. proprietario"></label><label>Valore<textarea rows="3" maxlength="4000" placeholder="Testo anche su piu righe">${escapeHtml(String(value))}</textarea></label><button type="button" title="Rimuovi attributo" aria-label="Rimuovi attributo">×</button>`;
|
||
row.querySelector("button").addEventListener("click", () => row.remove());
|
||
rows.appendChild(row);
|
||
};
|
||
entries.forEach(([key, value]) => addRow(key, value));
|
||
if (!entries.length) addRow();
|
||
const sourceById = new Map(sourceRecords.map(item => [item.id, item]));
|
||
dialog.querySelector("[data-copy-attributes]")?.addEventListener("click", () => {
|
||
const source = sourceById.get(dialog.querySelector("[data-copy-source]").value);
|
||
if (!source) { setInfo("Seleziona l'oggetto da cui copiare i metadati."); return; }
|
||
const includeValues = dialog.querySelector("[data-copy-values]").checked;
|
||
const existingRows = new Map([...rows.querySelectorAll(".attribute-editor-row")]
|
||
.map(row => [row.querySelector("input").value.trim().toLocaleLowerCase("it"), row])
|
||
.filter(([key]) => key));
|
||
const sourceEntries = editableManagedMetadataEntries(source);
|
||
sourceEntries.forEach(([key, value]) => {
|
||
const existing = existingRows.get(key.toLocaleLowerCase("it"));
|
||
if (existing) {
|
||
if (includeValues) existing.querySelector("textarea").value = String(value);
|
||
} else {
|
||
addRow(key, includeValues ? value : "");
|
||
}
|
||
});
|
||
setInfo(`${sourceEntries.length} metadati copiati${includeValues ? " con i valori" : " senza valori"}.`);
|
||
});
|
||
dialog.querySelector("[data-add-attribute]").addEventListener("click", () => addRow());
|
||
dialog.querySelector("[data-cancel]").addEventListener("click", () => dialog.remove());
|
||
dialog.querySelector("[data-save-attributes]").addEventListener("click", () => {
|
||
const parsed = {};
|
||
rows.querySelectorAll(".attribute-editor-row").forEach(row => {
|
||
const key = row.querySelector("input").value.trim().replace(/[^a-zA-Z0-9_ -]/g, "_").slice(0, 60);
|
||
const value = row.querySelector("textarea").value.slice(0, 4000);
|
||
if (key) parsed[key] = value;
|
||
});
|
||
updateManagedProperties([record], target => {
|
||
Object.keys(target).filter(key => !key.startsWith("_lme") && !reserved.has(key)).forEach(key => delete target[key]);
|
||
Object.assign(target, parsed);
|
||
});
|
||
setInfo(`${Object.keys(parsed).length} attributi aggiornati.`);
|
||
dialog.remove();
|
||
});
|
||
}
|
||
|
||
function utmFromLatLng(latitude, longitude) {
|
||
const zone = Math.floor((longitude + 180) / 6) + 1;
|
||
const central = (zone - 1) * 6 - 180 + 3;
|
||
const a = 6378137; const eccSquared = 0.00669438; const k0 = 0.9996;
|
||
const latRad = latitude * Math.PI / 180; const lonRad = longitude * Math.PI / 180; const lonOriginRad = central * Math.PI / 180;
|
||
const eccPrimeSquared = eccSquared / (1 - eccSquared);
|
||
const n = a / Math.sqrt(1 - eccSquared * Math.sin(latRad) ** 2);
|
||
const t = Math.tan(latRad) ** 2; const c = eccPrimeSquared * Math.cos(latRad) ** 2;
|
||
const aa = Math.cos(latRad) * (lonRad - lonOriginRad);
|
||
const m = a * ((1 - eccSquared / 4 - 3 * eccSquared ** 2 / 64 - 5 * eccSquared ** 3 / 256) * latRad
|
||
- (3 * eccSquared / 8 + 3 * eccSquared ** 2 / 32 + 45 * eccSquared ** 3 / 1024) * Math.sin(2 * latRad)
|
||
+ (15 * eccSquared ** 2 / 256 + 45 * eccSquared ** 3 / 1024) * Math.sin(4 * latRad)
|
||
- (35 * eccSquared ** 3 / 3072) * Math.sin(6 * latRad));
|
||
const easting = k0 * n * (aa + (1 - t + c) * aa ** 3 / 6 + (5 - 18 * t + t ** 2 + 72 * c - 58 * eccPrimeSquared) * aa ** 5 / 120) + 500000;
|
||
let northing = k0 * (m + n * Math.tan(latRad) * (aa ** 2 / 2 + (5 - t + 9 * c + 4 * c ** 2) * aa ** 4 / 24
|
||
+ (61 - 58 * t + t ** 2 + 600 * c - 330 * eccPrimeSquared) * aa ** 6 / 720));
|
||
if (latitude < 0) northing += 10000000;
|
||
return { zone, band: latitude >= 0 ? "N" : "S", easting, northing };
|
||
}
|
||
|
||
function recordCenter(record) {
|
||
if (typeof record?.layer?.getBounds === "function" && record.layer.getBounds().isValid()) return record.layer.getBounds().getCenter();
|
||
return record?.layer?.getLatLng?.() || null;
|
||
}
|
||
|
||
function showCoordinateInspectorForRecord(record) {
|
||
const point = recordCenter(record);
|
||
if (!point) { setInfo("Coordinate non disponibili per questo oggetto."); return; }
|
||
showCoordinateDialog(point, managedObjectEditableName(record) || "Oggetto selezionato");
|
||
}
|
||
|
||
function showCoordinateInspector() {
|
||
const records = [...lmeState.managerSelectedObjectIds].map(id => lmeState.managerRecords?.get(id)).filter(Boolean);
|
||
const highlighted = lmeState.managerRecords?.get(lmeState.managerHighlightedObjectId);
|
||
const record = highlighted || records[0];
|
||
const point = record ? recordCenter(record) : lmeState.map.getCenter();
|
||
showCoordinateDialog(point, record ? managedObjectEditableName(record) || "Oggetto selezionato" : "Centro mappa");
|
||
}
|
||
|
||
function showCoordinateDialog(point, title) {
|
||
const utm = utmFromLatLng(point.lat, point.lng);
|
||
const text = `WGS84: ${point.lat.toFixed(7)}, ${point.lng.toFixed(7)}\nUTM ${utm.zone}${utm.band}: E ${utm.easting.toFixed(2)} m, N ${utm.northing.toFixed(2)} m`;
|
||
showAppDialog(title, `<div class="coordinate-result"><strong>WGS84</strong><span>${point.lat.toFixed(7)}, ${point.lng.toFixed(7)}</span><strong>UTM zona ${utm.zone}${utm.band}</strong><span>E ${utm.easting.toFixed(2)} m<br>N ${utm.northing.toFixed(2)} m</span><button type="button" data-copy-coordinate>Copia coordinate</button></div>`);
|
||
document.querySelector("[data-copy-coordinate]")?.addEventListener("click", async () => {
|
||
await navigator.clipboard?.writeText(text);
|
||
setInfo("Coordinate copiate.");
|
||
});
|
||
}
|
||
|
||
function showAppDialog(title, bodyHtml) {
|
||
document.getElementById("lmeAppDialog")?.remove();
|
||
const overlay = document.createElement("div");
|
||
overlay.id = "lmeAppDialog";
|
||
overlay.className = "lme-app-dialog";
|
||
overlay.innerHTML = `<section role="dialog" aria-modal="true"><header><strong>${escapeHtml(title)}</strong><button type="button" aria-label="Chiudi">×</button></header><div class="lme-app-dialog-body">${bodyHtml}</div></section>`;
|
||
overlay.querySelector("header button").addEventListener("click", () => overlay.remove());
|
||
overlay.addEventListener("click", event => { if (event.target === overlay) overlay.remove(); });
|
||
document.querySelector(".map-app")?.appendChild(overlay);
|
||
return overlay;
|
||
}
|
||
|
||
function showFormDialog(title, fields, submitLabel = "Salva") {
|
||
return new Promise(resolve => {
|
||
const fieldHtml = fields.map(field => {
|
||
const common = `name="${escapeHtml(field.name)}" ${field.required === false ? "" : "required"} ${field.maxLength ? `maxlength="${field.maxLength}"` : ""}`;
|
||
const control = field.type === "textarea"
|
||
? `<textarea ${common} rows="${field.rows || 5}">${escapeHtml(field.value ?? "")}</textarea>`
|
||
: `<input ${common} type="${field.type || "text"}" value="${escapeHtml(field.value ?? "")}" ${field.step ? `step="${field.step}"` : ""}>`;
|
||
return `<label>${escapeHtml(field.label)}${control}${field.help ? `<small>${escapeHtml(field.help)}</small>` : ""}</label>`;
|
||
}).join("");
|
||
const dialog = showAppDialog(title, `<form class="lme-edit-form">${fieldHtml}<div class="dialog-command-row"><button type="button" data-cancel>Annulla</button><button type="submit" class="primary-command">${escapeHtml(submitLabel)}</button></div></form>`);
|
||
const form = dialog.querySelector("form");
|
||
let settled = false;
|
||
const finish = value => { if (settled) return; settled = true; resolve(value); dialog.remove(); };
|
||
dialog.querySelector("header button").addEventListener("click", () => finish(null), { capture: true });
|
||
dialog.querySelector("[data-cancel]").addEventListener("click", () => finish(null));
|
||
dialog.addEventListener("click", event => { if (event.target === dialog) finish(null); }, { capture: true });
|
||
form.addEventListener("submit", event => {
|
||
event.preventDefault();
|
||
const values = {};
|
||
fields.forEach(field => { values[field.name] = form.elements[field.name]?.value ?? ""; });
|
||
finish(values);
|
||
});
|
||
form.elements[fields[0]?.name]?.focus();
|
||
});
|
||
}
|
||
|
||
async function promptProjectName(currentName) {
|
||
const result = await showFormDialog("Rinomina progetto", [{
|
||
name: "name",
|
||
label: "Nuovo nome",
|
||
value: currentName || "",
|
||
maxLength: 80
|
||
}], "Rinomina");
|
||
return result?.name?.trim() || null;
|
||
}
|
||
|
||
function showObjectTable() {
|
||
const records = layerManagerCategories().flatMap(category => managedRecordsForCategory(category));
|
||
lmeState.managerRecords = new Map(records.map(record => [record.id, record]));
|
||
const body = records.map((record, index) => {
|
||
const properties = record.layer?.feature?.properties || {};
|
||
const details = managedObjectDetails(record.layer);
|
||
const selected = lmeState.managerSelectedObjectIds.has(record.id);
|
||
const locked = Boolean(properties._lmeLocked);
|
||
return `<tr data-object-id="${escapeHtml(record.id)}"><td><input type="checkbox" data-select="${escapeHtml(record.id)}" ${selected ? "checked" : ""} aria-label="Seleziona ${escapeHtml(managedObjectLabel(record, index))}"></td><td><input type="checkbox" data-lock="${escapeHtml(record.id)}" ${locked ? "checked" : ""} ${lmeState.readOnly ? "disabled" : ""} aria-label="Blocca ${escapeHtml(managedObjectLabel(record, index))}"></td><td><button type="button" data-locate="${escapeHtml(record.id)}" title="Mostra sulla mappa" aria-label="Mostra sulla mappa">⌖</button></td><td><button type="button" data-attributes="${escapeHtml(record.id)}" title="Modifica metadati" aria-label="Modifica metadati di ${escapeHtml(managedObjectLabel(record, index))}" ${lmeState.readOnly ? "disabled" : ""}>✎</button></td><td>${escapeHtml(managedObjectLabel(record, index))}</td><td>${escapeHtml(managedCategoryName(layerManagerCategories().find(item => item.key === record.categoryKey) || { key: record.categoryKey, label: record.categoryKey }, [record]))}</td><td>${escapeHtml(properties.foglio || properties.FOGLIO || "")}</td><td>${escapeHtml(properties.particella || properties.PARTICELLA || properties.LABEL || "")}</td><td>${escapeHtml(details.slice(0, 2).join(" | "))}</td></tr>`;
|
||
}).join("");
|
||
const dialog = showAppDialog("Elenco oggetti e particelle", `<label class="object-table-search">Filtra<input type="search" placeholder="Nome, foglio, particella o gruppo"></label><div class="object-table-legend"><span>Seleziona più oggetti con i flag nella colonna Selez.</span><span>Il lucchetto impedisce modifiche e rimozioni; la matita modifica i metadati.</span></div><div class="object-table-wrap"><table><thead><tr><th>Selez.</th><th>Blocca</th><th>Mappa</th><th>Metadati</th><th>Nome</th><th>Gruppo</th><th>Foglio</th><th>Particella</th><th>Misure</th></tr></thead><tbody>${body || "<tr><td colspan='9'>Nessun oggetto</td></tr>"}</tbody></table></div>`);
|
||
const byId = new Map(records.map(record => [record.id, record]));
|
||
dialog.querySelector("input[type='search']").addEventListener("input", event => {
|
||
const query = event.target.value.toLocaleLowerCase("it");
|
||
dialog.querySelectorAll("tbody tr").forEach(row => { row.hidden = query && !row.textContent.toLocaleLowerCase("it").includes(query); });
|
||
});
|
||
dialog.querySelectorAll("[data-select]").forEach(input => input.addEventListener("change", () => {
|
||
if (input.checked) lmeState.managerSelectedObjectIds.add(input.dataset.select);
|
||
else lmeState.managerSelectedObjectIds.delete(input.dataset.select);
|
||
const record = byId.get(input.dataset.select);
|
||
if (record && input.checked) {
|
||
lmeState.managerHighlightedObjectId = record.id;
|
||
lmeState.managerHighlightedCategoryKey = record.categoryKey;
|
||
renderManagerHighlight([record]);
|
||
}
|
||
refreshLayerManager();
|
||
setInfo(`${lmeState.managerSelectedObjectIds.size} oggetti selezionati.`);
|
||
}));
|
||
dialog.querySelectorAll("[data-lock]").forEach(input => input.addEventListener("change", () => {
|
||
const record = byId.get(input.dataset.lock);
|
||
if (!record) return;
|
||
updateManagedProperties([record], properties => { properties._lmeLocked = input.checked; });
|
||
setInfo(input.checked ? "Oggetto bloccato." : "Oggetto sbloccato.");
|
||
}));
|
||
dialog.querySelectorAll("[data-locate]").forEach(button => button.addEventListener("click", () => {
|
||
const record = byId.get(button.dataset.locate); if (!record) return;
|
||
dialog.remove(); highlightManagedObject(record);
|
||
}));
|
||
dialog.querySelectorAll("[data-attributes]").forEach(button => button.addEventListener("click", () => {
|
||
const record = byId.get(button.dataset.attributes); if (!record) return;
|
||
dialog.remove(); editManagedAttributes(record);
|
||
}));
|
||
return records.length;
|
||
}
|
||
|
||
function showAuditLog() {
|
||
const rows = [...lmeState.auditLog].reverse().map(item => `<tr><td>${escapeHtml(new Date(item.at).toLocaleString("it-IT"))}</td><td>${escapeHtml(item.action || "Modifica mappa")}</td></tr>`).join("");
|
||
showAppDialog("Registro operazioni", `<div class="object-table-wrap"><table><thead><tr><th>Data e ora</th><th>Operazione</th></tr></thead><tbody>${rows || "<tr><td colspan='2'>Nessuna operazione registrata</td></tr>"}</tbody></table></div>`);
|
||
return lmeState.auditLog.length;
|
||
}
|
||
|
||
function updateManagedProperties(records, change) {
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||
rememberMapChange();
|
||
records.forEach(record => {
|
||
record.layer.feature ||= { type: "Feature", properties: {} };
|
||
record.layer.feature.properties ||= {};
|
||
change(record.layer.feature.properties);
|
||
applyStyleFromProperties(record.layer);
|
||
updateStoredFeaturePopup(record.layer);
|
||
});
|
||
refreshPermanentLabels();
|
||
refreshLayerManager();
|
||
finishMapChange();
|
||
}
|
||
|
||
function applyManagedStyle(records, style) {
|
||
updateManagedProperties(records, properties => {
|
||
properties._lmeColor = style.color;
|
||
properties._lmeFillColor = style.fillColor || style.color;
|
||
properties._lmeWeight = style.weight;
|
||
properties._lmeOpacity = style.opacity;
|
||
if (style.fillOpacity !== undefined) properties._lmeFillOpacity = style.fillOpacity;
|
||
if (style.dash !== undefined) properties._lmeDash = style.dash;
|
||
});
|
||
}
|
||
|
||
function applyStyleFromProperties(layer) {
|
||
const properties = layer?.feature?.properties || {};
|
||
if (typeof layer?.setStyle === "function") {
|
||
layer.setStyle({
|
||
color: properties._lmeColor || layer.options.color,
|
||
fillColor: properties._lmeFillColor || layer.options.fillColor,
|
||
weight: Number(properties._lmeWeight) || layer.options.weight,
|
||
opacity: Number.isFinite(Number(properties._lmeOpacity)) ? Number(properties._lmeOpacity) : layer.options.opacity,
|
||
fillOpacity: Number.isFinite(Number(properties._lmeFillOpacity)) ? Number(properties._lmeFillOpacity) : layer.options.fillOpacity,
|
||
dashArray: properties._lmeDash || null
|
||
});
|
||
}
|
||
}
|
||
|
||
function refreshPermanentLabels() {
|
||
lmeState.permanentLabels?.clearLayers();
|
||
layerManagerCategories().forEach(category => managedRecordsForCategory(category).forEach((record, index) => {
|
||
const properties = record.layer?.feature?.properties || {};
|
||
if (!properties._lmeLabelVisible || lmeState.managerHiddenObjectIds.has(record.id)) return;
|
||
const center = layerLabelPosition(record.layer); if (!center) return;
|
||
L.marker(center, { pane: "tooltipPane", interactive: false, icon: L.divIcon({ className: "lme-permanent-label", html: `<span>${escapeHtml(managedObjectLabel(record, index))}</span>`, iconSize: null }) }).addTo(lmeState.permanentLabels);
|
||
}));
|
||
}
|
||
|
||
function layerLabelPosition(layer) {
|
||
if (typeof layer?.getBounds === "function" && layer.getBounds().isValid()) return layer.getBounds().getCenter();
|
||
if (typeof layer?.getLatLng === "function") return layer.getLatLng();
|
||
return null;
|
||
}
|
||
|
||
function fileToDataUrl(file) {
|
||
return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); });
|
||
}
|
||
|
||
function addDrawingFeature(feature, select = false) {
|
||
let added = null;
|
||
L.geoJSON(feature, {
|
||
pane: "drawingsPane",
|
||
style: storedFeatureStyle,
|
||
pointToLayer: (item, latlng) => item?.properties?._lmeShape === "circle" && Number(item.properties._lmeRadius) > 0
|
||
? L.circle(latlng, { pane: "drawingsPane", renderer: lmeState.drawingsRenderer, radius: Number(item.properties._lmeRadius) })
|
||
: L.marker(latlng, { pane: "drawingsPane", draggable: false }),
|
||
onEachFeature: (item, layer) => {
|
||
annotateDrawingLayer(layer, item?.properties?._lmeShape);
|
||
applyStyleFromProperties(layer);
|
||
updateStoredFeaturePopup(layer);
|
||
lmeState.drawings.addLayer(layer);
|
||
added = layer;
|
||
}
|
||
});
|
||
if (select && added) selectDrawingLayer(added);
|
||
return added;
|
||
}
|
||
|
||
function storedFeatureStyle(feature) {
|
||
const properties = feature?.properties || {};
|
||
return {
|
||
renderer: lmeState.drawingsRenderer,
|
||
color: properties._lmeColor || "#f97316",
|
||
fillColor: properties._lmeFillColor || "#fb923c",
|
||
weight: Number(properties._lmeWeight) || 3,
|
||
opacity: Number.isFinite(Number(properties._lmeOpacity)) ? Number(properties._lmeOpacity) : 1,
|
||
fillOpacity: Number.isFinite(Number(properties._lmeFillOpacity)) ? Number(properties._lmeFillOpacity) : 0.2,
|
||
dashArray: properties._lmeDash || null
|
||
};
|
||
}
|
||
|
||
function duplicateManagedRecords(records) {
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) { setInfo("Sblocca gli oggetti da duplicare."); return; }
|
||
if (!canAddDrawingObjects(records.length)) return;
|
||
rememberMapChange();
|
||
records.forEach((record, index) => {
|
||
const feature = record.layer.toGeoJSON();
|
||
feature.properties = { ...(feature.properties || {}), name: `${managedObjectLabel(record, index)} - copia`, _lmeManagerId: undefined };
|
||
if (record.layer instanceof L.Circle) {
|
||
const point = turf.transformTranslate(feature, 1.5, 45, { units: "meters" });
|
||
point.properties._lmeShape = "circle";
|
||
point.properties._lmeRadius = record.layer.getRadius();
|
||
addDrawingFeature(point);
|
||
} else {
|
||
addDrawingFeature(turf.transformTranslate(feature, 1.5, 45, { units: "meters" }));
|
||
}
|
||
});
|
||
refreshDrawingMeasurements(); refreshLayerManager(); finishMapChange();
|
||
setInfo(`${records.length} ${records.length === 1 ? "oggetto duplicato" : "oggetti duplicati"}.`);
|
||
}
|
||
|
||
async function moveManagedRecords(records) {
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) { setInfo("Sblocca gli oggetti da spostare."); return; }
|
||
const result = await showFormDialog("Sposta oggetti", [
|
||
{ name: "north", label: "Metri verso nord (negativo verso sud)", type: "number", value: "0", step: "0.01" },
|
||
{ name: "east", label: "Metri verso est (negativo verso ovest)", type: "number", value: "0", step: "0.01" }
|
||
], "Sposta");
|
||
if (!result) return;
|
||
const north = Number(result.north);
|
||
if (!Number.isFinite(north)) return;
|
||
const east = Number(result.east);
|
||
if (!Number.isFinite(east) || (!north && !east)) return;
|
||
const distance = Math.hypot(north, east);
|
||
const bearing = Math.atan2(east, north) * 180 / Math.PI;
|
||
rememberMapChange();
|
||
records.forEach(record => {
|
||
const moved = turf.transformTranslate(record.layer.toGeoJSON(), distance, bearing, { units: "meters" });
|
||
replaceManagedLayer(record, moved);
|
||
});
|
||
finishManagedGeometryChange(`Spostati ${records.length} oggetti.`);
|
||
}
|
||
|
||
async function bufferManagedRecords(records) {
|
||
if (!requireProFeature("advancedGeometry")) return;
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||
const result = await showFormDialog("Crea buffer", [{ name: "meters", label: "Distanza in metri", type: "number", value: "5", step: "0.01" }], "Crea buffer");
|
||
if (!result) return;
|
||
const meters = Number(result.meters);
|
||
if (!Number.isFinite(meters) || meters <= 0) return;
|
||
rememberMapChange();
|
||
let count = 0;
|
||
records.forEach((record, index) => {
|
||
const buffered = turf.buffer(record.layer.toGeoJSON(), meters, { units: "meters" });
|
||
if (!buffered) return;
|
||
buffered.properties = { ...(record.layer.feature?.properties || {}), name: `Buffer ${meters} m - ${managedObjectLabel(record, index)}`, _lmeShape: "polygon", _lmeManagerId: undefined };
|
||
addDrawingFeature(buffered); count++;
|
||
});
|
||
finishManagedGeometryChange(`Creati ${count} buffer da ${formatDistance(meters)}.`);
|
||
}
|
||
|
||
function unionManagedRecords(records) {
|
||
if (!requireProFeature("advancedGeometry")) return;
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||
const polygons = records.filter(record => record.layer instanceof L.Polygon).map(record => record.layer.toGeoJSON());
|
||
if (polygons.length < 2) { setInfo("Per unire seleziona almeno due aree o poligoni."); return; }
|
||
try {
|
||
const merged = turf.union(turf.featureCollection(polygons));
|
||
if (!merged) throw new Error("le aree non producono un risultato");
|
||
rememberMapChange();
|
||
merged.properties = { ...(polygons[0].properties || {}), name: "Unione aree", _lmeShape: "polygon", _lmeManagerId: undefined };
|
||
addDrawingFeature(merged, true);
|
||
polygons.forEach((_feature, index) => removeRecordWithoutPrompt(records.filter(record => record.layer instanceof L.Polygon)[index]));
|
||
lmeState.managerSelectedObjectIds.clear();
|
||
finishManagedGeometryChange("Aree unite in un nuovo oggetto.");
|
||
} catch (error) { setInfo(`Unione non riuscita: ${error.message}`); }
|
||
}
|
||
|
||
function splitManagedRecords(records) {
|
||
if (!requireProFeature("advancedGeometry")) return;
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||
const polygonRecord = records.find(record => record.layer instanceof L.Polygon);
|
||
const lineRecord = records.find(record => record.layer instanceof L.Polyline && !(record.layer instanceof L.Polygon));
|
||
if (!polygonRecord || !lineRecord) { setInfo("Per dividere seleziona un poligono e una linea di taglio."); return; }
|
||
try {
|
||
const cutter = turf.buffer(lineRecord.layer.toGeoJSON(), 0.02, { units: "meters" });
|
||
const split = turf.difference(turf.featureCollection([polygonRecord.layer.toGeoJSON(), cutter]));
|
||
const parts = split ? turf.flatten(split).features : [];
|
||
if (parts.length < 2) throw new Error("la linea deve attraversare completamente il poligono");
|
||
rememberMapChange();
|
||
parts.forEach((part, index) => {
|
||
part.properties = { ...(polygonRecord.layer.feature?.properties || {}), name: `${managedObjectLabel(polygonRecord, 0)} - parte ${index + 1}`, _lmeManagerId: undefined };
|
||
addDrawingFeature(part);
|
||
});
|
||
removeRecordWithoutPrompt(polygonRecord);
|
||
lmeState.managerSelectedObjectIds.clear();
|
||
finishManagedGeometryChange(`Poligono diviso in ${parts.length} parti.`);
|
||
} catch (error) { setInfo(`Divisione non riuscita: ${error.message}`); }
|
||
}
|
||
|
||
function selectedManagedRecords() {
|
||
const current = new Map(layerManagerCategories()
|
||
.flatMap(category => managedRecordsForCategory(category))
|
||
.map(record => [record.id, record]));
|
||
lmeState.managerRecords = current;
|
||
return [...lmeState.managerSelectedObjectIds].map(id => current.get(id)).filter(Boolean);
|
||
}
|
||
|
||
function createHoleFromSelected() {
|
||
if (!requireProFeature("advancedGeometry")) return false;
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
|
||
const polygons = selectedManagedRecords().filter(record => record.layer instanceof L.Polygon);
|
||
if (polygons.length !== 2) { showGeometryResult("Creazione foro", "Seleziona esattamente due poligoni: l'area esterna e l'area interna da escludere.", "warning"); return false; }
|
||
try {
|
||
const ordered = [...polygons].sort((left, right) => turf.area(right.layer.toGeoJSON()) - turf.area(left.layer.toGeoJSON()));
|
||
const outer = ordered[0].layer.toGeoJSON();
|
||
const inner = ordered[1].layer.toGeoJSON();
|
||
if (!turf.booleanWithin(inner, outer)) throw new Error("il poligono interno deve essere completamente contenuto nell'area esterna");
|
||
const result = turf.difference(turf.featureCollection([outer, inner]));
|
||
if (!result) throw new Error("nessun risultato geometrico");
|
||
rememberMapChange();
|
||
result.properties = { ...(outer.properties || {}), name: `${managedObjectLabel(ordered[0], 0)} con area esclusa`, _lmeManagerId: undefined };
|
||
addDrawingFeature(result, true);
|
||
ordered.forEach(removeRecordWithoutPrompt);
|
||
lmeState.managerSelectedObjectIds.clear();
|
||
finishManagedGeometryChange("Poligono con foro creato correttamente.");
|
||
showGeometryResult("Foro creato", "Il nuovo poligono contiene l'area interna esclusa. I due oggetti originali sono stati sostituiti.", "success");
|
||
return true;
|
||
} catch (error) { showGeometryResult("Creazione foro non riuscita", error.message, "error"); return false; }
|
||
}
|
||
|
||
function validateAndRepairSelected() {
|
||
if (!requireProFeature("advancedGeometry")) return "LME Pro richiesto";
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return "Progetto in sola lettura"; }
|
||
const records = selectedManagedRecords();
|
||
if (!records.length) { showGeometryResult("Controlla e ripara", "Seleziona uno o più oggetti dalla mappa, dal pannello Layer o dalla tabella oggetti.", "warning"); return "Nessuna selezione"; }
|
||
let valid = 0; let repaired = 0; let rejected = 0;
|
||
rememberMapChange();
|
||
records.forEach((record, index) => {
|
||
try {
|
||
let feature = turf.cleanCoords(record.layer.toGeoJSON(), { mutate: false });
|
||
if (feature.geometry?.type === "Polygon" && typeof turf.booleanValid === "function" && !turf.booleanValid(feature)) {
|
||
const parts = turf.unkinkPolygon(feature).features;
|
||
if (!parts.length) throw new Error("geometria non riparabile");
|
||
removeRecordWithoutPrompt(record);
|
||
parts.forEach((part, partIndex) => {
|
||
part.properties = { ...(feature.properties || {}), name: `${managedObjectLabel(record, index)} - riparato ${partIndex + 1}`, _lmeManagerId: undefined };
|
||
addDrawingFeature(part);
|
||
});
|
||
repaired++;
|
||
} else {
|
||
if (JSON.stringify(feature.geometry) !== JSON.stringify(record.layer.toGeoJSON().geometry)) {
|
||
replaceManagedLayer(record, feature); repaired++;
|
||
} else valid++;
|
||
}
|
||
} catch { rejected++; }
|
||
});
|
||
lmeState.managerSelectedObjectIds.clear();
|
||
const message = `Controllati ${records.length} oggetti: ${valid} validi, ${repaired} riparati, ${rejected} non riparabili.`;
|
||
finishManagedGeometryChange(message);
|
||
showGeometryResult("Controllo geometrie completato", message, rejected ? "warning" : "success");
|
||
return `${valid} valide, ${repaired} riparate, ${rejected} non riparabili`;
|
||
}
|
||
|
||
function compareSelectedGeometries() {
|
||
if (!requireProFeature("advancedGeometry")) return false;
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
|
||
const polygons = selectedManagedRecords().filter(record => record.layer instanceof L.Polygon);
|
||
if (polygons.length !== 2) { showGeometryResult("Confronta due confini", "Seleziona esattamente due poligoni dalla mappa, dal pannello Layer o dalla tabella oggetti.", "warning"); return false; }
|
||
try {
|
||
const first = polygons[0].layer.toGeoJSON(); const second = polygons[1].layer.toGeoJSON();
|
||
const onlyFirst = turf.difference(turf.featureCollection([first, second]));
|
||
const onlySecond = turf.difference(turf.featureCollection([second, first]));
|
||
const overlap = turf.intersect(turf.featureCollection([first, second]));
|
||
rememberMapChange();
|
||
const addResult = (feature, name, color) => {
|
||
if (!feature) return;
|
||
feature.properties = { name, _lmeColor: color, _lmeFillColor: color, _lmeFillOpacity: 0.38, _lmeManagerId: undefined };
|
||
addDrawingFeature(feature);
|
||
};
|
||
addResult(onlyFirst, `Solo ${managedObjectLabel(polygons[0], 0)}`, "#ef4444");
|
||
addResult(onlySecond, `Solo ${managedObjectLabel(polygons[1], 1)}`, "#3b82f6");
|
||
addResult(overlap, "Sovrapposizione", "#16a34a");
|
||
const firstArea = onlyFirst ? turf.area(onlyFirst) : 0; const secondArea = onlySecond ? turf.area(onlySecond) : 0; const overlapArea = overlap ? turf.area(overlap) : 0;
|
||
const message = `Confronto creato. Solo primo confine: ${formatArea(firstArea)}. Solo secondo confine: ${formatArea(secondArea)}. Sovrapposizione: ${formatArea(overlapArea)}.`;
|
||
finishManagedGeometryChange(message);
|
||
showGeometryResult("Confronto completato", `${message} Sulla mappa: rosso solo primo, blu solo secondo, verde sovrapposizione.`, "success");
|
||
return true;
|
||
} catch (error) { showGeometryResult("Confronto non riuscito", error.message, "error"); return false; }
|
||
}
|
||
|
||
function showGeometryResult(title, message, tone = "success") {
|
||
setInfo(`${title}: ${message}`);
|
||
const icons = { success: "✓", warning: "!", error: "×" };
|
||
showAppDialog(title, `<div class="geometry-result geometry-result-${tone}"><span aria-hidden="true">${icons[tone] || "i"}</span><p>${escapeHtml(message)}</p></div>`);
|
||
}
|
||
|
||
function showLeafletToolbarHelp() {
|
||
showAppDialog("Guida strumenti mappa", `<div class="leaflet-help">
|
||
<p>Gli strumenti operano direttamente sulla mappa. Per le azioni su oggetti esistenti, seleziona prima le righe nel pannello Layer, nella tabella oggetti oppure tocca un oggetto sulla mappa.</p>
|
||
<dl class="leaflet-help-list">
|
||
<div><dt>+</dt><dd><strong>Ingrandisci</strong>Aumenta lo zoom mantenendo il centro attuale. Esempio: avvicinati a un vertice per posizionarlo con maggiore precisione.</dd></div>
|
||
<div><dt>−</dt><dd><strong>Riduci</strong>Allarga l'area visibile. Esempio: usalo per controllare insieme particelle appartenenti a fogli vicini.</dd></div>
|
||
<div><dt>●</dt><dd><strong>Punto di interesse</strong>Tocca la posizione da memorizzare. Esempio: segna un cancello, un cippo o il punto di accesso al fondo.</dd></div>
|
||
<div><dt>/</dt><dd><strong>Linea</strong>Tocca i vertici del percorso e termina con doppio tocco o sul primo comando della barra. Durante il disegno viene mostrata la distanza; utile per un fronte strada o una recinzione.</dd></div>
|
||
<div><dt>⬠</dt><dd><strong>Poligono</strong>Tocca almeno tre vertici e chiudi la figura. Esempio: delimita un'area di intervento; l'app calcola area e perimetro.</dd></div>
|
||
<div><dt>□</dt><dd><strong>Rettangolo</strong>Trascina tra due angoli opposti. Esempio: crea rapidamente l'ingombro rettangolare di un fabbricato.</dd></div>
|
||
<div><dt>○</dt><dd><strong>Cerchio</strong>Tocca il centro e trascina fino al raggio desiderato. Esempio: rappresenta una fascia di rispetto attorno a un pozzo.</dd></div>
|
||
<div><dt>✎</dt><dd><strong>Modifica disegni</strong>Premi la matita e scegli Solo selezionato oppure Tutti. Sposta i vertici e conferma con Salva; gli oggetti bloccati devono prima essere sbloccati.</dd></div>
|
||
<div><dt>▱</dt><dd><strong>Elimina disegni</strong>Premi il cestino e scegli Solo selezionato oppure Tutti. L'oggetto rimosso può essere recuperato con Annulla o dal cestino negli strumenti avanzati.</dd></div>
|
||
<div><dt>✓</dt><dd><strong>Controlla e ripara</strong>Verifica gli oggetti selezionati e prova a correggere coordinate duplicate o poligoni non validi. Esempio: usalo quando un contorno importato non si esporta o si riempie male; al termine compare un riepilogo.</dd></div>
|
||
<div><dt>◉</dt><dd><strong>Crea foro</strong>Seleziona esattamente due poligoni: prima l'area esterna, poi quella interna. Esempio: sottrai il cortile dal perimetro di un fabbricato o un'isola esclusa da un'area di lavoro.</dd></div>
|
||
<div><dt>⇄</dt><dd><strong>Confronta due confini</strong>Seleziona esattamente due poligoni. L'app crea il solo primo in rosso, il solo secondo in blu e la sovrapposizione in verde, mostrando le rispettive aree.</dd></div>
|
||
<div><dt>PF</dt><dd><strong>Scarica Punti Fiduciali</strong>Seleziona una particella o un foglio catastale; in alternativa usa la particella rilevata dal GPS. I PF vengono aggiunti come sottogruppo del relativo foglio.</dd></div>
|
||
<div><dt>?</dt><dd><strong>Guida</strong>Riapre questa descrizione. I risultati di riparazione e confronto vengono mostrati anche in una finestra, così l'operazione non resta senza riscontro.</dd></div>
|
||
</dl>
|
||
</div>`);
|
||
}
|
||
|
||
function addGeometryToolsControl() {
|
||
if (lmeState.geometryToolsControl || !lmeState.map) return;
|
||
const GeometryTools = L.Control.extend({
|
||
options: { position: "topright" },
|
||
onAdd() {
|
||
const container = L.DomUtil.create("div", "leaflet-bar lme-geometry-toolbar");
|
||
const actions = [
|
||
{ icon: "✓", title: "Controlla e ripara geometrie", run: validateAndRepairSelected },
|
||
{ icon: "◉", title: "Crea foro da due poligoni", run: createHoleFromSelected },
|
||
{ icon: "⇄", title: "Confronta due confini", run: compareSelectedGeometries },
|
||
{ icon: "PF", title: "Scarica PF del foglio o della particella", run: downloadFiducialsForMapContext, fiducials: true },
|
||
{ icon: "?", title: "Guida strumenti mappa", run: showLeafletToolbarHelp }
|
||
];
|
||
actions.forEach(action => {
|
||
const button = L.DomUtil.create("a", "", container);
|
||
button.href = "#";
|
||
button.title = action.title;
|
||
button.setAttribute("role", "button");
|
||
button.setAttribute("aria-label", action.title);
|
||
button.textContent = action.icon;
|
||
if (action.fiducials) {
|
||
button.classList.add("lme-fiducial-download");
|
||
lmeState.fiducialDownloadButton = button;
|
||
}
|
||
L.DomEvent.on(button, "click", event => { L.DomEvent.stop(event); action.run(); });
|
||
});
|
||
L.DomEvent.disableClickPropagation(container);
|
||
return container;
|
||
}
|
||
});
|
||
lmeState.geometryToolsControl = new GeometryTools().addTo(lmeState.map);
|
||
updateFiducialDownloadButton();
|
||
}
|
||
|
||
function downloadFiducialsForMapContext() {
|
||
if (!requireProFeature("fiducialPoints")) return false;
|
||
const context = currentFiducialMapContext();
|
||
if (!context) {
|
||
setInfo("Seleziona una particella o un foglio catastale. Se il GPS è attivo puoi usare anche la particella corrente.");
|
||
return false;
|
||
}
|
||
if (!lmeState.dotNetReference) {
|
||
setInfo("Servizio PF non ancora pronto. Riprova tra poco.");
|
||
return false;
|
||
}
|
||
void lmeState.dotNetReference.invokeMethodAsync(
|
||
"DownloadFiducialsForMapSelection",
|
||
context.municipalityCode,
|
||
context.sheet,
|
||
context.label);
|
||
return true;
|
||
}
|
||
|
||
function currentFiducialMapContext() {
|
||
const selected = selectedManagedRecords();
|
||
if (!selected.length && lmeState.managerHighlightedObjectId) {
|
||
const highlighted = lmeState.managerRecords?.get?.(lmeState.managerHighlightedObjectId);
|
||
if (highlighted) selected.push(highlighted);
|
||
}
|
||
const preferred = selected.filter(record => ["selectedParcels", "gpsParcels", "importedSheets", "imported"].includes(record.categoryKey));
|
||
for (const record of preferred) {
|
||
const context = fiducialContextFromLayer(record.layer);
|
||
if (context) return context;
|
||
}
|
||
|
||
const gpsLayers = managedRecordsForCategory(layerManagerCategories().find(category => category.key === "gpsParcels"));
|
||
for (const record of gpsLayers) {
|
||
const context = fiducialContextFromLayer(record.layer);
|
||
if (context) return context;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function fiducialContextFromLayer(layer) {
|
||
const properties = layer?.feature?.properties || {};
|
||
const municipalityCode = String(properties._lmeMunicipalityCode || "").trim().toUpperCase();
|
||
const sheet = normalizeFiducialSheet(properties._lmeSheet || properties.foglio || properties.FOGLIO || properties.sheet || properties.SHEET);
|
||
if (/^[A-Z][0-9A-Z]{3}$/.test(municipalityCode) && sheet) {
|
||
return { municipalityCode, sheet, label: `Comune ${municipalityCode}, foglio ${sheet}` };
|
||
}
|
||
|
||
const references = [
|
||
properties.NATIONALCADASTRALREFERENCE,
|
||
properties.NATIONALCADASTRALZONINGREFERENCE,
|
||
properties.INSPIREID_LOCALID,
|
||
properties._lmeParcelReference,
|
||
properties.LABEL
|
||
].filter(Boolean).map(value => String(value).toUpperCase());
|
||
for (const reference of references) {
|
||
const match = reference.match(/(?:^|\.)([A-Z][0-9A-Z]{3})[_-](\d{4})(?:\d{2})?(?:[._-]|$)/);
|
||
if (match) {
|
||
const parsedSheet = normalizeFiducialSheet(match[2]);
|
||
return { municipalityCode: match[1], sheet: parsedSheet, label: `Comune ${match[1]}, foglio ${parsedSheet}` };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function normalizeFiducialSheet(value) {
|
||
const digits = String(value ?? "").match(/\d+/)?.[0] || "";
|
||
if (!digits) return "";
|
||
return String(Number.parseInt(digits, 10));
|
||
}
|
||
|
||
function updateFiducialDownloadButton() {
|
||
const button = lmeState.fiducialDownloadButton;
|
||
if (!button) return;
|
||
const enabled = Boolean(lmeState.entitlements?.fiducialPoints && currentFiducialMapContext());
|
||
button.classList.toggle("is-disabled", !enabled);
|
||
button.setAttribute("aria-disabled", String(!enabled));
|
||
button.title = enabled
|
||
? "Scarica PF del foglio o della particella"
|
||
: "Seleziona una particella o un foglio per scaricare i PF";
|
||
}
|
||
|
||
async function numericEditManagedRecord(record) {
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||
if (record.layer?.feature?.properties?._lmeLocked) { setInfo("Sblocca prima l'oggetto."); return; }
|
||
if (record.layer instanceof L.Circle) {
|
||
const center = record.layer.getLatLng();
|
||
const result = await showFormDialog("Modifica cerchio", [{ name: "coordinates", label: "Latitudine, longitudine, raggio in metri", type: "textarea", rows: 3, value: `${center.lat.toFixed(7)}, ${center.lng.toFixed(7)}, ${record.layer.getRadius().toFixed(2)}` }], "Applica");
|
||
if (!result) return;
|
||
const value = result.coordinates;
|
||
const parts = value.split(/[;, ]+/).map(Number).filter(Number.isFinite);
|
||
if (parts.length < 3) { setInfo("Inserisci latitudine, longitudine e raggio validi."); return; }
|
||
rememberMapChange(); record.layer.setLatLng([parts[0], parts[1]]); record.layer.setRadius(parts[2]); annotateDrawingLayer(record.layer, "circle");
|
||
} else if (typeof record.layer?.getLatLng === "function") {
|
||
const point = record.layer.getLatLng();
|
||
const result = await showFormDialog("Modifica punto", [{ name: "coordinates", label: "Latitudine, longitudine", type: "textarea", rows: 3, value: `${point.lat.toFixed(7)}, ${point.lng.toFixed(7)}` }], "Applica");
|
||
if (!result) return;
|
||
const value = result.coordinates;
|
||
const parts = value.split(/[;, ]+/).map(Number).filter(Number.isFinite);
|
||
if (parts.length < 2) { setInfo("Inserisci coordinate valide."); return; }
|
||
rememberMapChange(); record.layer.setLatLng([parts[0], parts[1]]);
|
||
} else {
|
||
const lines = lineLatLngGroups(record.layer.getLatLngs());
|
||
const current = lines[0]?.map(point => `${point.lat.toFixed(7)},${point.lng.toFixed(7)}`).join("; ") || "";
|
||
const result = await showFormDialog("Modifica vertici", [{ name: "coordinates", label: "Coppie latitudine,longitudine separate da punto e virgola", type: "textarea", rows: 8, value: current }], "Applica");
|
||
if (!result) return;
|
||
const value = result.coordinates;
|
||
const points = value.split(";").map(pair => pair.split(",").map(Number)).filter(pair => pair.length === 2 && pair.every(Number.isFinite));
|
||
if (points.length < 2) { setInfo("Servono almeno due vertici validi."); return; }
|
||
rememberMapChange(); record.layer.setLatLngs(points.map(point => L.latLng(point[0], point[1])));
|
||
}
|
||
finishManagedGeometryChange("Coordinate aggiornate.");
|
||
}
|
||
|
||
function replaceManagedLayer(record, feature) {
|
||
const parent = record.parent?.hasLayer?.(record.layer) ? record.parent : lmeState.drawings;
|
||
parent.removeLayer?.(record.layer);
|
||
clearManagedObjectState(record.id);
|
||
feature.properties = { ...(record.layer.feature?.properties || {}), ...(feature.properties || {}), _lmeManagerId: undefined };
|
||
addDrawingFeature(feature);
|
||
}
|
||
|
||
function removeRecordWithoutPrompt(record) {
|
||
record.parent?.removeLayer?.(record.layer); clearManagedObjectState(record.id);
|
||
}
|
||
|
||
function finishManagedGeometryChange(message) {
|
||
refreshImportedMeasurements(); refreshDrawingMeasurements(); refreshPermanentLabels(); updateGpsInsideState(); refreshLayerManager(); finishMapChange(); setInfo(message);
|
||
}
|
||
|
||
function rememberMapChange() {
|
||
if (lmeState.historyBusy || !lmeState.map) return;
|
||
const snapshot = createProjectSnapshot(true);
|
||
if (lmeState.undoStack.at(-1) !== snapshot) lmeState.undoStack.push(snapshot);
|
||
if (lmeState.undoStack.length > 30) lmeState.undoStack.shift();
|
||
lmeState.redoStack = [];
|
||
}
|
||
|
||
function finishMapChange() {
|
||
if (lmeState.historyBusy || !lmeState.map) return;
|
||
const action = arguments[0] || "Modifica mappa";
|
||
lmeState.isDirty = true;
|
||
lmeState.auditLog.push({ at: new Date().toISOString(), action });
|
||
if (lmeState.auditLog.length > 250) lmeState.auditLog.shift();
|
||
if (lmeState.autoSaveEnabled) {
|
||
try { localStorage.setItem("lmeAutosaveV3", createProjectSnapshot(true)); } catch { }
|
||
}
|
||
window.clearTimeout(lmeState.changeNotifyTimer);
|
||
lmeState.changeNotifyTimer = window.setTimeout(() => {
|
||
void lmeState.dotNetReference?.invokeMethodAsync("OnMapChanged", action);
|
||
}, 450);
|
||
}
|
||
|
||
function getHistoryState() {
|
||
return { canUndo: lmeState.undoStack.length > 0, canRedo: lmeState.redoStack.length > 0, dirty: lmeState.isDirty };
|
||
}
|
||
|
||
function markProjectSaved() {
|
||
lmeState.isDirty = false;
|
||
void lmeState.dotNetReference?.invokeMethodAsync("OnProjectSaved");
|
||
}
|
||
|
||
function confirmDiscardChanges(actionLabel) {
|
||
return !lmeState.isDirty || window.confirm(`Ci sono modifiche non salvate. Continuare e ${actionLabel || "abbandonarle"}?`);
|
||
}
|
||
|
||
function setProjectReadOnly(enabled) {
|
||
lmeState.readOnly = Boolean(enabled);
|
||
document.querySelector(".map-app")?.classList.toggle("project-readonly", lmeState.readOnly);
|
||
if (lmeState.readOnly) setDrawToolsVisible(false);
|
||
refreshLayerManager();
|
||
setInfo(lmeState.readOnly ? "Progetto aperto in sola lettura. Duplicalo per modificarlo." : "Progetto modificabile.");
|
||
}
|
||
|
||
function undoMapChange() {
|
||
if (!lmeState.undoStack.length) { setInfo("Nessuna operazione da annullare."); return; }
|
||
lmeState.redoStack.push(createProjectSnapshot(true));
|
||
restoreHistorySnapshot(lmeState.undoStack.pop(), "Operazione annullata.");
|
||
lmeState.isDirty = true;
|
||
}
|
||
|
||
function redoMapChange() {
|
||
if (!lmeState.redoStack.length) { setInfo("Nessuna operazione da ripristinare."); return; }
|
||
lmeState.undoStack.push(createProjectSnapshot(true));
|
||
restoreHistorySnapshot(lmeState.redoStack.pop(), "Operazione ripristinata.");
|
||
lmeState.isDirty = true;
|
||
}
|
||
|
||
function restoreLastTrashItem() {
|
||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
|
||
const entry = lmeState.trash.pop();
|
||
if (!entry) { setInfo("Il cestino e' vuoto."); return false; }
|
||
const drawingsToRestore = entry.items.filter(item =>
|
||
!["imported", "importedSheets", "fiducials"].includes(item.categoryKey)).length;
|
||
if (!canAddDrawingObjects(drawingsToRestore)) {
|
||
lmeState.trash.push(entry);
|
||
return false;
|
||
}
|
||
rememberMapChange();
|
||
entry.items.forEach(item => {
|
||
if (item.categoryKey === "imported") addImportedFeatures({ type: "FeatureCollection", features: [item.feature] });
|
||
else if (item.categoryKey === "importedSheets") addImportedSheetFeatures({ type: "FeatureCollection", features: [item.feature] });
|
||
else if (item.categoryKey === "fiducials") {
|
||
L.geoJSON(item.feature, {
|
||
pointToLayer: (feature, latlng) => createFiducialMarker(feature, latlng),
|
||
onEachFeature: (feature, layer) => bindFiducialPopup(feature, layer)
|
||
}).eachLayer(layer => lmeState.fiducials.addLayer(layer));
|
||
}
|
||
else addDrawingFeature(item.feature);
|
||
});
|
||
refreshLayerManager();
|
||
finishManagedGeometryChange(`Ripristinati ${entry.items.length} oggetti dal cestino.`);
|
||
return true;
|
||
}
|
||
|
||
function emptyTrash() {
|
||
if (!lmeState.trash.length) { setInfo("Il cestino e' gia vuoto."); return 0; }
|
||
if (!window.confirm(`Eliminare definitivamente ${lmeState.trash.reduce((sum, entry) => sum + entry.items.length, 0)} oggetti dal cestino?`)) return lmeState.trash.length;
|
||
lmeState.trash = [];
|
||
setInfo("Cestino svuotato.");
|
||
return 0;
|
||
}
|
||
|
||
function restoreHistorySnapshot(snapshot, message) {
|
||
lmeState.historyBusy = true;
|
||
try { loadProjectSnapshot(snapshot, "cronologia"); setInfo(message); } finally { lmeState.historyBusy = false; }
|
||
}
|
||
|
||
function restoreAutosave() {
|
||
const snapshot = localStorage.getItem("lmeAutosaveV3");
|
||
if (!snapshot) { setInfo("Nessun recupero automatico disponibile."); return false; }
|
||
rememberMapChange(); loadProjectSnapshot(snapshot, "recupero automatico"); return true;
|
||
}
|
||
|
||
function setAdvancedOptions(options) {
|
||
const value = options || {};
|
||
lmeState.snapEnabled = value.snapEnabled !== false;
|
||
lmeState.snapDistancePixels = Math.max(4, Math.min(40, Number(value.snapDistancePixels) || 14));
|
||
lmeState.autoSaveEnabled = value.autoSaveEnabled !== false;
|
||
lmeState.autoFitSelectedObject = Boolean(value.autoFitSelectedObject);
|
||
lmeState.gpsProximityEnabled = value.gpsProximityEnabled !== false;
|
||
lmeState.gpsBoundaryAlertEnabled = Boolean(value.gpsBoundaryAlertEnabled);
|
||
}
|
||
|
||
function compareProjectSnapshots(currentRaw, previousRaw) {
|
||
const current = typeof currentRaw === "string" ? JSON.parse(currentRaw) : currentRaw;
|
||
const previous = typeof previousRaw === "string" ? JSON.parse(previousRaw) : previousRaw;
|
||
const collect = snapshot => ["drawings", "imported", "importedSheets", "selectedParcels"]
|
||
.flatMap(key => snapshot?.[key]?.features || []);
|
||
const keyFor = (feature, index) => feature?.properties?._lmeManagerId
|
||
|| `${feature?.properties?.name || feature?.properties?.Name || "object"}:${index}`;
|
||
const currentMap = new Map(collect(current).map((feature, index) => [keyFor(feature, index), JSON.stringify(feature)]));
|
||
const previousMap = new Map(collect(previous).map((feature, index) => [keyFor(feature, index), JSON.stringify(feature)]));
|
||
let added = 0; let removed = 0; let changed = 0;
|
||
currentMap.forEach((value, key) => { if (!previousMap.has(key)) added++; else if (previousMap.get(key) !== value) changed++; });
|
||
previousMap.forEach((_value, key) => { if (!currentMap.has(key)) removed++; });
|
||
const message = `Confronto revisione: ${added} aggiunti, ${removed} rimossi, ${changed} modificati.`;
|
||
setInfo(message);
|
||
return message;
|
||
}
|
||
|
||
function renderLayerManagerVisibilityOption(label, checked, onChange) {
|
||
const option = document.createElement("label");
|
||
option.className = "layer-manager-suboption";
|
||
option.innerHTML = `<input type="checkbox" ${checked ? "checked" : ""}><span>${escapeHtml(label)}</span>`;
|
||
const checkbox = option.querySelector("input");
|
||
checkbox.addEventListener("click", event => event.stopPropagation());
|
||
checkbox.addEventListener("change", () => onChange(checkbox.checked));
|
||
return option;
|
||
}
|
||
|
||
function geometryVisibilityLabel(layer) {
|
||
if (layer instanceof L.Polygon || layer instanceof L.Circle) {
|
||
return "Area/geometria sulla mappa";
|
||
}
|
||
if (layer instanceof L.Polyline) {
|
||
return "Linea sulla mappa";
|
||
}
|
||
return "Punto sulla mappa";
|
||
}
|
||
|
||
function supportsManagedMeasurements(record) {
|
||
return ["imported", "drawings"].includes(record.categoryKey)
|
||
&& (record.layer instanceof L.Circle
|
||
|| record.layer instanceof L.Polygon
|
||
|| record.layer instanceof L.Polyline);
|
||
}
|
||
|
||
function managedMeasurementsGloballyVisible(categoryKey) {
|
||
return categoryKey === "imported"
|
||
? lmeState.showImportedMeasurements
|
||
: lmeState.showDrawingMeasurements;
|
||
}
|
||
|
||
function managedMeasurementKinds(record) {
|
||
if (record.layer instanceof L.Polygon) {
|
||
return [
|
||
{ key: "summary", label: "Superficie e perimetro sulla mappa" },
|
||
{ key: "sides", label: "Misure dei lati sulla mappa" }
|
||
];
|
||
}
|
||
if (record.layer instanceof L.Circle) {
|
||
return [{ key: "summary", label: "Superficie, raggio e circonferenza sulla mappa" }];
|
||
}
|
||
return [
|
||
{ key: "summary", label: "Lunghezza totale sulla mappa" },
|
||
{ key: "sides", label: "Misure dei tratti sulla mappa" }
|
||
];
|
||
}
|
||
|
||
function managedMeasurementHiddenSet(kind) {
|
||
return kind === "sides"
|
||
? lmeState.managerHiddenMeasurementSideIds
|
||
: lmeState.managerHiddenMeasurementSummaryIds;
|
||
}
|
||
|
||
function setManagedMeasurementKindVisible(record, kind, visible) {
|
||
const hiddenSet = managedMeasurementHiddenSet(kind);
|
||
if (visible) {
|
||
if (!managedMeasurementsGloballyVisible(record.categoryKey)) {
|
||
lmeState.managerRecords.forEach(candidate => {
|
||
if (candidate.categoryKey === record.categoryKey
|
||
&& supportsManagedMeasurements(candidate)) {
|
||
lmeState.managerHiddenMeasurementSummaryIds.add(candidate.id);
|
||
lmeState.managerHiddenMeasurementSideIds.add(candidate.id);
|
||
}
|
||
});
|
||
if (record.categoryKey === "imported") {
|
||
lmeState.showImportedMeasurements = true;
|
||
} else {
|
||
lmeState.showDrawingMeasurements = true;
|
||
}
|
||
void lmeState.dotNetReference?.invokeMethodAsync(
|
||
"SetMeasurementsFromLayerManager",
|
||
record.categoryKey,
|
||
true);
|
||
}
|
||
hiddenSet.delete(record.id);
|
||
} else {
|
||
hiddenSet.add(record.id);
|
||
}
|
||
refreshImportedMeasurements();
|
||
refreshDrawingMeasurements();
|
||
refreshLayerManager();
|
||
}
|
||
|
||
function captureLayerManagerExpansion(container) {
|
||
container.querySelectorAll("details[data-expansion-key]").forEach(row => {
|
||
if (row.open) {
|
||
lmeState.managerExpandedRows.add(row.dataset.expansionKey);
|
||
} else {
|
||
lmeState.managerExpandedRows.delete(row.dataset.expansionKey);
|
||
}
|
||
});
|
||
}
|
||
|
||
function bindLayerManagerExpansion(row, key) {
|
||
row.dataset.expansionKey = key;
|
||
row.open = lmeState.managerExpandedRows.has(key);
|
||
row.addEventListener("toggle", () => {
|
||
if (row.open) {
|
||
lmeState.managerExpandedRows.add(key);
|
||
} else {
|
||
lmeState.managerExpandedRows.delete(key);
|
||
}
|
||
const button = row.querySelector(":scope > summary > .layer-expand-button");
|
||
if (button) {
|
||
button.textContent = row.open ? "▾" : "▸";
|
||
button.setAttribute("aria-expanded", String(row.open));
|
||
}
|
||
});
|
||
}
|
||
|
||
function layerExpandButton(expanded, label) {
|
||
return `<button type="button" class="layer-expand-button" aria-label="${expanded ? "Comprimi" : "Espandi"} ${escapeHtml(label)}" aria-expanded="${expanded}">${expanded ? "▾" : "▸"}</button>`;
|
||
}
|
||
|
||
function bindLayerManagerToggleButton(row) {
|
||
const summary = row.querySelector(":scope > summary");
|
||
const button = summary?.querySelector(".layer-expand-button");
|
||
summary?.addEventListener("click", event => event.preventDefault());
|
||
button?.addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
row.open = !row.open;
|
||
});
|
||
}
|
||
|
||
function setManagedCategoryVisible(category, visible) {
|
||
if (category.key === "imported") {
|
||
lmeState.importedVisible = visible;
|
||
} else if (category.key === "importedSheets") {
|
||
lmeState.importedSheetsVisible = visible;
|
||
} else if (category.key === "selectedParcels") {
|
||
lmeState.selectedParcelsVisible = visible;
|
||
} else if (category.key === "fiducials") {
|
||
lmeState.fiducialsVisible = 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;
|
||
}
|
||
if (categoryKey === "fiducials") {
|
||
return lmeState.fiducialsVisible;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function highlightManagedObject(record) {
|
||
if (lmeState.managerHighlightedObjectId === record.id) {
|
||
clearManagerHighlight();
|
||
return;
|
||
}
|
||
clearManagerHighlight();
|
||
lmeState.managerHighlightedObjectId = record.id;
|
||
lmeState.managerHighlightedCategoryKey = record.categoryKey;
|
||
document.querySelectorAll(".layer-manager-object").forEach(row => {
|
||
row.classList.toggle("layer-manager-object-selected", row.dataset.objectId === record.id);
|
||
});
|
||
setSelectedDrawing(record.categoryKey === "drawings" ? record.layer : null);
|
||
renderManagerHighlight([record]);
|
||
if (lmeState.autoFitSelectedObject) {
|
||
fitLayer(record.layer);
|
||
}
|
||
revealManagedRecordInLayerManager(record.id);
|
||
}
|
||
|
||
function revealManagedRecordInLayerManager(recordId) {
|
||
const row = document.querySelector(`.layer-manager-object[data-object-id="${CSS.escape(recordId)}"]`);
|
||
if (!row) return;
|
||
const block = row.closest(".layer-manager-block");
|
||
const group = row.closest(".layer-manager-group");
|
||
if (group) group.classList.add("layer-manager-selection-path");
|
||
if (block) block.classList.add("layer-manager-selection-path");
|
||
row.classList.add("layer-manager-object-selected");
|
||
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||
}
|
||
|
||
function drawingEditToolbar() {
|
||
return lmeState.drawControl?._toolbars?.[L.EditToolbar.TYPE] || null;
|
||
}
|
||
|
||
function drawingEditActive() {
|
||
return Boolean(drawingEditToolbar()?.enabled?.());
|
||
}
|
||
|
||
function updateDrawingSelectionActions() {
|
||
const hasSelection = Boolean(lmeState.drawingSelection?.getLayers?.().length);
|
||
const hasDrawings = Boolean(lmeState.drawings?.getLayers?.().length);
|
||
lmeState.drawingDeleteButton?.classList.toggle("leaflet-disabled", !hasDrawings);
|
||
if (lmeState.drawingDeleteButton) {
|
||
lmeState.drawingDeleteButton.setAttribute("aria-disabled", String(!hasDrawings));
|
||
}
|
||
lmeState.drawingScopeMenu?.querySelector("[data-drawing-scope='selected']")
|
||
?.setAttribute("aria-disabled", String(!hasSelection));
|
||
}
|
||
|
||
function setSelectedDrawing(layer) {
|
||
if (!lmeState.drawingSelection || drawingEditActive()) {
|
||
return;
|
||
}
|
||
lmeState.drawingSelection.clearLayers();
|
||
if (layer) {
|
||
lmeState.drawingSelection.addLayer(layer);
|
||
}
|
||
updateDrawingSelectionActions();
|
||
}
|
||
|
||
function selectDrawingLayer(layer) {
|
||
if (drawingEditActive()) {
|
||
return;
|
||
}
|
||
const record = {
|
||
id: managedLayerId(layer, "drawings"),
|
||
categoryKey: "drawings",
|
||
layer,
|
||
parent: lmeState.drawings,
|
||
source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente")
|
||
};
|
||
clearManagerHighlight();
|
||
lmeState.managerHighlightedObjectId = record.id;
|
||
lmeState.managerHighlightedCategoryKey = "drawings";
|
||
document.querySelectorAll(".layer-manager-object").forEach(row => {
|
||
row.classList.toggle("layer-manager-object-selected", row.dataset.objectId === record.id);
|
||
});
|
||
setSelectedDrawing(layer);
|
||
renderManagerHighlight([record]);
|
||
setInfo(`Selezione: ${managedObjectLabel(record, 0)}. Usa la matita per modificare o il cestino per eliminare.`);
|
||
}
|
||
|
||
function bindManagedParentHighlight(row, key, records) {
|
||
const summary = row.querySelector(":scope > summary");
|
||
row.dataset.highlightKey = key;
|
||
row.classList.toggle("layer-manager-parent-selected", lmeState.managerHighlightedParentKey === key);
|
||
if (lmeState.managerHighlightedParentKey === key) {
|
||
lmeState.managerHighlightedParentRecords = records;
|
||
}
|
||
row.querySelector(":scope > summary > .layer-manager-title").addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
highlightManagedParent(row, key, records);
|
||
});
|
||
summary.addEventListener("pointerenter", () => {
|
||
lmeState.managerHoveredParentKey = key;
|
||
lmeState.managerHoveredParentRecords = records;
|
||
row.classList.add("layer-manager-parent-highlighted");
|
||
renderManagerHighlight(records);
|
||
});
|
||
summary.addEventListener("pointerleave", () => {
|
||
if (lmeState.managerHoveredParentKey !== key) {
|
||
return;
|
||
}
|
||
lmeState.managerHoveredParentKey = null;
|
||
lmeState.managerHoveredParentRecords = null;
|
||
row.classList.remove("layer-manager-parent-highlighted");
|
||
renderSelectedManagerHighlight();
|
||
});
|
||
}
|
||
|
||
function highlightManagedParent(row, key, records) {
|
||
if (lmeState.managerHighlightedParentKey === key) {
|
||
clearManagerHighlight();
|
||
return;
|
||
}
|
||
clearManagerHighlight();
|
||
lmeState.managerHighlightedParentKey = key;
|
||
lmeState.managerHighlightedParentRecords = records;
|
||
lmeState.managerHighlightedCategoryKey = records[0]?.categoryKey || null;
|
||
setSelectedDrawing(null);
|
||
row.classList.add("layer-manager-parent-selected");
|
||
renderManagerHighlight(records);
|
||
}
|
||
|
||
function renderSelectedManagerHighlight() {
|
||
if (lmeState.managerHighlightedParentRecords?.length) {
|
||
renderManagerHighlight(lmeState.managerHighlightedParentRecords);
|
||
return;
|
||
}
|
||
const selected = lmeState.managerRecords.get(lmeState.managerHighlightedObjectId);
|
||
renderManagerHighlight(selected ? [selected] : []);
|
||
}
|
||
|
||
function renderManagerHighlight(records) {
|
||
lmeState.managerHighlight?.clearLayers();
|
||
const features = records.flatMap(record => {
|
||
const geojson = record.layer?.toGeoJSON?.();
|
||
if (!geojson) {
|
||
return [];
|
||
}
|
||
return geojson.type === "FeatureCollection" ? geojson.features : [geojson];
|
||
});
|
||
if (!features.length) {
|
||
return;
|
||
}
|
||
L.geoJSON({ type: "FeatureCollection", features }, {
|
||
pane: "managerHighlightPane",
|
||
interactive: false,
|
||
style: { color: "#ff00d4", weight: 8, opacity: 1, fillColor: "#00fff0", fillOpacity: 0.16 },
|
||
pointToLayer: (feature, latlng) => L.circleMarker(latlng, {
|
||
pane: "managerHighlightPane",
|
||
radius: 12,
|
||
color: "#ff00d4",
|
||
weight: 5,
|
||
fillColor: "#00fff0",
|
||
fillOpacity: 0.9,
|
||
interactive: false
|
||
})
|
||
}).addTo(lmeState.managerHighlight);
|
||
}
|
||
|
||
function clearManagerHighlight() {
|
||
lmeState.managerHighlight?.clearLayers();
|
||
lmeState.managerHighlightedObjectId = null;
|
||
lmeState.managerHighlightedCategoryKey = null;
|
||
lmeState.managerHighlightedParentKey = null;
|
||
lmeState.managerHighlightedParentRecords = null;
|
||
lmeState.managerHoveredParentKey = null;
|
||
lmeState.managerHoveredParentRecords = null;
|
||
setSelectedDrawing(null);
|
||
document.querySelectorAll(".layer-manager-object-selected").forEach(row => {
|
||
row.classList.remove("layer-manager-object-selected");
|
||
});
|
||
document.querySelectorAll(".layer-manager-parent-highlighted").forEach(row => {
|
||
row.classList.remove("layer-manager-parent-highlighted");
|
||
});
|
||
document.querySelectorAll(".layer-manager-parent-selected").forEach(row => {
|
||
row.classList.remove("layer-manager-parent-selected");
|
||
});
|
||
}
|
||
|
||
function layerManagerMoveButtons(label) {
|
||
return `<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));
|
||
});
|
||
}
|
||
|
||
async function setImportedSheetLayerVisible(enabled) {
|
||
migrateMisclassifiedImportedSheets();
|
||
lmeState.importedSheetsVisible = Boolean(enabled);
|
||
let sheetResult = null;
|
||
if (lmeState.importedSheetsVisible) {
|
||
setInfo("Carico i fogli catastali delle particelle...");
|
||
sheetResult = await ensureImportedSheetOutlines();
|
||
}
|
||
applyImportedSheetVisibility();
|
||
const count = lmeState.importedSheets?.toGeoJSON()?.features?.length || 0;
|
||
setInfo(lmeState.importedSheetsVisible
|
||
? (count
|
||
? `${sheetResult?.added ? "Fogli catastali ufficiali caricati" : "Layer dei fogli catastali visibile"} (${count} cornici).`
|
||
: (sheetResult?.failed
|
||
? "Il servizio ufficiale dei fogli catastali non e' al momento disponibile. Riprova mostrando nuovamente il layer."
|
||
: "Nessuna particella con cui individuare il foglio catastale."))
|
||
: "Layer del foglio importato nascosto; le particelle restano visibili.");
|
||
}
|
||
|
||
function removeLegacyGeneratedSheetOutlines() {
|
||
const category = layerManagerCategories().find(item => item.key === "importedSheets");
|
||
if (!category) return 0;
|
||
const generated = managedRecordsForCategory(category).filter(record =>
|
||
Boolean(record.layer?.feature?.properties?._lmeGeneratedSheetOutline));
|
||
generated.forEach(record => {
|
||
record.parent?.removeLayer?.(record.layer);
|
||
clearManagedObjectState(record.id);
|
||
});
|
||
return generated.length;
|
||
}
|
||
|
||
function cadastralSheetIdentity(feature, index) {
|
||
const properties = feature?.properties || {};
|
||
const explicit = properties.foglio ?? properties.FOGLIO ?? properties.sheet ?? properties.SHEET;
|
||
const administrativeUnit = String(properties.ADMINISTRATIVEUNIT || "").trim().toUpperCase();
|
||
if (String(explicit ?? "").trim()) {
|
||
return `${administrativeUnit}|${String(explicit).trim().replace(/^0+/, "") || "0"}`;
|
||
}
|
||
const context = [
|
||
properties.NATIONALCADASTRALREFERENCE,
|
||
properties.INSPIREID_LOCALID,
|
||
properties._lmeParcelReference,
|
||
properties._lmeSource,
|
||
properties._lmeFolder,
|
||
properties.name,
|
||
properties.Name
|
||
].filter(Boolean).join(" ").toUpperCase();
|
||
const namedSheet = context.match(/\bFOGLIO\s*[_ .-]*0*(\d+[A-Z]?)\b/i);
|
||
const codedSheet = context.match(/\b([A-Z]\d{3})[._-]([0-9]{4}[A-Z0-9]{2})(?=[._/-]|\b)/i);
|
||
if (namedSheet) return `${administrativeUnit}|${namedSheet[1]}`;
|
||
if (codedSheet) return `${codedSheet[1]}|${codedSheet[2]}`;
|
||
return `geometria-${index}`;
|
||
}
|
||
|
||
function sheetRepresentativePoint(feature) {
|
||
try {
|
||
return turf.pointOnFeature(feature).geometry.coordinates;
|
||
} catch {
|
||
const bounds = geoJsonBounds(feature?.geometry);
|
||
return bounds ? boundsCenter(bounds) : null;
|
||
}
|
||
}
|
||
|
||
function cadastralSheetFeatureKey(feature) {
|
||
const properties = feature?.properties || {};
|
||
return String(properties.NATIONALCADASTRALZONINGREFERENCE
|
||
|| properties.INSPIREID_LOCALID
|
||
|| properties.LABEL
|
||
|| JSON.stringify(feature?.geometry || {})).trim().toUpperCase();
|
||
}
|
||
|
||
async function ensureImportedSheetOutlines() {
|
||
if (!lmeState.imported || !lmeState.importedSheets) {
|
||
return { added: 0, failed: false };
|
||
}
|
||
|
||
removeLegacyGeneratedSheetOutlines();
|
||
const existingAreaSheets = layerManagerCategories()
|
||
.flatMap(category => managedRecordsForCategory(category))
|
||
.map(record => ({ categoryKey: record.categoryKey, feature: record.layer?.toGeoJSON?.() }))
|
||
.filter(item => ["Polygon", "MultiPolygon"].includes(item.feature?.geometry?.type)
|
||
&& !item.feature?.properties?._lmeGeneratedSheetOutline
|
||
&& (item.categoryKey === "importedSheets"
|
||
|| item.feature?.properties?._lmeOfficialSheet
|
||
|| item.feature?.properties?.NATIONALCADASTRALZONINGREFERENCE
|
||
|| isSheetOrOverlayFeature(item.feature)))
|
||
.map(item => item.feature);
|
||
|
||
const parcelFeatures = [
|
||
...(lmeState.imported.toGeoJSON()?.features || []),
|
||
...(lmeState.selectedParcelsLayer?.toGeoJSON?.()?.features || [])
|
||
].filter(feature => ["Polygon", "MultiPolygon"].includes(feature?.geometry?.type));
|
||
const representatives = new Map();
|
||
parcelFeatures.forEach((feature, index) => {
|
||
const identity = cadastralSheetIdentity(feature, index);
|
||
if (!representatives.has(identity)) representatives.set(identity, feature);
|
||
});
|
||
if (!representatives.size || !lmeState.dotNetReference) {
|
||
return { added: 0, failed: false };
|
||
}
|
||
|
||
const existingKeys = new Set(existingAreaSheets.map(cadastralSheetFeatureKey));
|
||
const officialSheets = new Map();
|
||
let failures = 0;
|
||
for (const [identity, parcelFeature] of representatives) {
|
||
const point = sheetRepresentativePoint(parcelFeature);
|
||
if (!point) continue;
|
||
const existingSheet = findContainingPolygon(point, {
|
||
type: "FeatureCollection",
|
||
features: [...existingAreaSheets, ...officialSheets.values()]
|
||
});
|
||
if (existingSheet) continue;
|
||
try {
|
||
const xml = await lmeState.dotNetReference.invokeMethodAsync(
|
||
"GetOfficialCadastralSheetsNear",
|
||
point[1],
|
||
point[0]);
|
||
const candidates = gmlParcelsToGeoJson(xml).features || [];
|
||
const containing = findContainingPolygon(point, { type: "FeatureCollection", features: candidates });
|
||
const sheet = containing || candidates[0];
|
||
if (!sheet) continue;
|
||
const key = cadastralSheetFeatureKey(sheet);
|
||
if (existingKeys.has(key) || officialSheets.has(key)) continue;
|
||
const reference = String(sheet.properties?.NATIONALCADASTRALZONINGREFERENCE
|
||
|| sheet.properties?.LABEL
|
||
|| identity.split("|").pop()
|
||
|| "").trim();
|
||
sheet.properties = {
|
||
...(sheet.properties || {}),
|
||
name: reference ? `Foglio catastale ${reference}` : "Foglio catastale",
|
||
foglio: reference,
|
||
_lmeSheetIdentity: identity,
|
||
_lmeOfficialSheet: true,
|
||
_lmeSource: reference ? `Foglio catastale ${reference}` : "Fogli catastali AdE",
|
||
_lmeGroupName: reference ? `Foglio ${reference}` : "Fogli catastali AdE"
|
||
};
|
||
officialSheets.set(key, sheet);
|
||
} catch {
|
||
failures++;
|
||
}
|
||
}
|
||
|
||
if (officialSheets.size) {
|
||
addImportedSheetFeatures({ type: "FeatureCollection", features: [...officialSheets.values()] });
|
||
applyImportedSheetVisibility();
|
||
refreshLayerManager();
|
||
finishMapChange(`${officialSheets.size} fogli catastali ufficiali caricati.`);
|
||
}
|
||
return { added: officialSheets.size, failed: failures > 0 && !officialSheets.size };
|
||
}
|
||
|
||
function migrateMisclassifiedImportedSheets() {
|
||
if (!lmeState.imported || !lmeState.importedSheets) {
|
||
return 0;
|
||
}
|
||
|
||
const current = lmeState.imported.toGeoJSON();
|
||
const misplaced = (current.features || []).filter(isSheetOrOverlayFeature);
|
||
if (!misplaced.length) {
|
||
return 0;
|
||
}
|
||
|
||
const retained = (current.features || []).filter(feature => !isSheetOrOverlayFeature(feature));
|
||
lmeState.imported.clearLayers();
|
||
addImportedFeatures({ type: "FeatureCollection", features: retained });
|
||
addImportedSheetFeatures({ type: "FeatureCollection", features: misplaced });
|
||
refreshImportedMeasurements();
|
||
return misplaced.length;
|
||
}
|
||
|
||
function addImportedFeatures(geojson) {
|
||
const layer = L.geoJSON(geojson || emptyFeatureCollection(), {
|
||
pane: "importedPane",
|
||
style: { color: "#22c55e", weight: 3, opacity: 0.95, fillOpacity: 0.18 },
|
||
pointToLayer: (feature, latlng) => L.circleMarker(latlng, {
|
||
pane: "importedPane",
|
||
radius: 7,
|
||
color: "#22c55e",
|
||
fillColor: "#ffffff",
|
||
fillOpacity: 0.9
|
||
}),
|
||
onEachFeature: (feature, layer) => { bindStoredFeaturePopup(feature, layer); applyStyleFromProperties(layer); bindManagedMapSelection(layer, "imported", lmeState.imported); }
|
||
}).addTo(lmeState.imported);
|
||
layer.lmeManagerLabel = managedGeoJsonLabel(geojson, "Dati importati");
|
||
return layer;
|
||
}
|
||
|
||
function addImportedSheetFeatures(geojson) {
|
||
const layer = L.geoJSON(geojson || emptyFeatureCollection(), {
|
||
pane: "importedSheetPane",
|
||
style: { color: "#f59e0b", weight: 4, opacity: 1, fillOpacity: 0.03, dashArray: "10 6" },
|
||
pointToLayer: (feature, latlng) => L.circleMarker(latlng, {
|
||
pane: "importedSheetPane",
|
||
radius: 7,
|
||
color: "#f59e0b",
|
||
fillColor: "#ffffff",
|
||
fillOpacity: 0.85
|
||
}),
|
||
onEachFeature: (feature, layer) => { bindStoredFeaturePopup(feature, layer); applyStyleFromProperties(layer); bindManagedMapSelection(layer, "importedSheets", lmeState.importedSheets); }
|
||
}).addTo(lmeState.importedSheets);
|
||
layer.lmeManagerLabel = managedGeoJsonLabel(geojson, "Cornice o foglio");
|
||
return layer;
|
||
}
|
||
|
||
function applyImportedSheetVisibility() {
|
||
if (!lmeState.map || !lmeState.importedSheets) {
|
||
return;
|
||
}
|
||
if (lmeState.importedSheetsVisible) {
|
||
if (!lmeState.map.hasLayer(lmeState.importedSheets)) {
|
||
lmeState.importedSheets.addTo(lmeState.map);
|
||
}
|
||
lmeState.importedSheets.bringToFront();
|
||
return;
|
||
}
|
||
if (lmeState.map.hasLayer(lmeState.importedSheets)) {
|
||
lmeState.map.removeLayer(lmeState.importedSheets);
|
||
}
|
||
}
|
||
|
||
function setImportedMeasurementsVisible(enabled) {
|
||
lmeState.showImportedMeasurements = Boolean(enabled);
|
||
refreshImportedMeasurements();
|
||
refreshLayerManager();
|
||
setInfo(lmeState.showImportedMeasurements
|
||
? "Misure dei file GIS visibili."
|
||
: "Misure dei file GIS nascoste.");
|
||
}
|
||
|
||
function setDrawingMeasurementsVisible(enabled) {
|
||
lmeState.showDrawingMeasurements = Boolean(enabled);
|
||
refreshDrawingMeasurements();
|
||
refreshLayerManager();
|
||
setInfo(lmeState.showDrawingMeasurements
|
||
? "Misure dei disegni visibili."
|
||
: "Misure dei disegni nascoste.");
|
||
}
|
||
|
||
function fitImported() {
|
||
const layers = [lmeState.imported];
|
||
if (lmeState.importedSheetsVisible) {
|
||
layers.push(lmeState.importedSheets);
|
||
}
|
||
fitLayer(L.featureGroup(layers));
|
||
}
|
||
|
||
function clearImported() {
|
||
rememberMapChange();
|
||
clearManagedCategoryState("imported");
|
||
clearManagedCategoryState("importedSheets");
|
||
lmeState.imported.clearLayers();
|
||
lmeState.importedSheets.clearLayers();
|
||
lmeState.importedSheetsVisible = false;
|
||
applyImportedSheetVisibility();
|
||
lmeState.importedMeasurements.clearLayers();
|
||
setInfo("File GIS rimossi.");
|
||
updateGpsInsideState();
|
||
refreshLayerManager();
|
||
finishMapChange();
|
||
}
|
||
|
||
function addProjectMarker(name) {
|
||
if (!canAddDrawingObjects()) return false;
|
||
rememberMapChange();
|
||
const center = lmeState.map.getCenter();
|
||
const label = (name || "Nuovo POI").trim();
|
||
const marker = L.marker(center, { pane: "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();
|
||
return true;
|
||
}
|
||
|
||
function configureDrawLabels() {
|
||
L.drawLocal.draw.toolbar.buttons.polygon = lmeState.labels.measureArea;
|
||
L.drawLocal.draw.toolbar.buttons.polyline = lmeState.labels.measureDistance;
|
||
L.drawLocal.draw.toolbar.buttons.circle = lmeState.labels.measureCircle;
|
||
L.drawLocal.draw.toolbar.buttons.rectangle = lmeState.labels.drawRectangle;
|
||
L.drawLocal.draw.toolbar.buttons.marker = lmeState.labels.addPoi;
|
||
L.drawLocal.edit.toolbar.buttons.edit = "Scegli quali disegni modificare";
|
||
L.drawLocal.edit.toolbar.buttons.remove = "Scegli quali disegni eliminare";
|
||
L.drawLocal.draw.handlers.polygon.tooltip.start = "Tocca il primo vertice dell'area.";
|
||
L.drawLocal.draw.handlers.polygon.tooltip.cont = "Tocca per aggiungere un vertice.";
|
||
L.drawLocal.draw.handlers.polygon.tooltip.end = "Tocca il primo punto per chiudere l'area.";
|
||
L.drawLocal.draw.handlers.polyline.tooltip.start = "Tocca il primo punto della distanza.";
|
||
L.drawLocal.draw.handlers.polyline.tooltip.cont = "Tocca per aggiungere un tratto.";
|
||
L.drawLocal.draw.handlers.polyline.tooltip.end = "Tocca l'ultimo punto per terminare.";
|
||
L.drawLocal.draw.handlers.circle.tooltip.start = "Tocca il centro e trascina per il raggio.";
|
||
}
|
||
|
||
function bindDrawingEvents() {
|
||
if (!lmeState.map || lmeState.drawEventsBound) {
|
||
return;
|
||
}
|
||
lmeState.map.on(L.Draw.Event.CREATED, event => {
|
||
if (lmeState.readOnly) {
|
||
setInfo("Il progetto e' in sola lettura.");
|
||
return;
|
||
}
|
||
if (!canAddDrawingObjects()) return;
|
||
snapDrawingLayer(event.layer);
|
||
annotateDrawingLayer(event.layer, event.layerType);
|
||
lmeState.drawings.addLayer(event.layer);
|
||
refreshDrawingMeasurements();
|
||
updateGpsInsideState();
|
||
const completedLabels = {
|
||
polygon: "Area completata: superficie, perimetro e lati aggiornati.",
|
||
rectangle: "Rettangolo completato: superficie, perimetro e lati aggiornati.",
|
||
polyline: "Distanza completata: lunghezza totale e tratti aggiornati.",
|
||
circle: "Cerchio completato: superficie, raggio e circonferenza aggiornati.",
|
||
marker: "Punto aggiunto alla mappa."
|
||
};
|
||
setInfo(completedLabels[event.layerType] || "Disegno completato.");
|
||
refreshLayerManager();
|
||
selectDrawingLayer(event.layer);
|
||
finishMapChange();
|
||
});
|
||
lmeState.map.on(L.Draw.Event.DRAWSTART, rememberMapChange);
|
||
lmeState.map.on(L.Draw.Event.EDITSTART, rememberMapChange);
|
||
lmeState.map.on(L.Draw.Event.EDITED, event => {
|
||
event.layers.eachLayer(layer => { snapDrawingLayer(layer); annotateDrawingLayer(layer); });
|
||
refreshDrawingMeasurements();
|
||
updateGpsInsideState();
|
||
refreshLayerManager();
|
||
finishMapChange();
|
||
});
|
||
lmeState.map.on(L.Draw.Event.DELETED, event => {
|
||
event.layers?.eachLayer(layer => lmeState.drawings.removeLayer(layer));
|
||
clearManagerHighlight();
|
||
refreshDrawingMeasurements();
|
||
updateGpsInsideState();
|
||
refreshLayerManager();
|
||
finishMapChange();
|
||
});
|
||
lmeState.map.on(L.Draw.Event.DRAWSTOP, () => {
|
||
lmeState.activeDrawHandler = null;
|
||
});
|
||
lmeState.drawEventsBound = true;
|
||
}
|
||
|
||
function enableDrawTools() {
|
||
if (lmeState.drawControl) {
|
||
setInfo("Strumenti di disegno gia attivi.");
|
||
return;
|
||
}
|
||
|
||
configureDrawLabels();
|
||
const orangeArea = {
|
||
pane: "drawingsPane",
|
||
renderer: lmeState.drawingsRenderer,
|
||
color: "#f97316",
|
||
weight: 4,
|
||
fillColor: "#fb923c",
|
||
fillOpacity: 0.2
|
||
};
|
||
lmeState.drawControl = new L.Control.Draw({
|
||
position: "topright",
|
||
edit: {
|
||
featureGroup: lmeState.drawings,
|
||
remove: false,
|
||
edit: {
|
||
selectedPathOptions: {
|
||
color: "#ff00d4",
|
||
fillColor: "#00fff0",
|
||
fillOpacity: 0.16,
|
||
maintainColor: false
|
||
}
|
||
}
|
||
},
|
||
draw: {
|
||
circle: { metric: usesMetricMeasurements(), feet: usesImperialMeasurements(), showRadius: true, shapeOptions: orangeArea },
|
||
circlemarker: false,
|
||
marker: { zIndexOffset: 0 },
|
||
polygon: {
|
||
metric: usesMetricMeasurements(),
|
||
feet: usesImperialMeasurements(),
|
||
showArea: true,
|
||
allowIntersection: false,
|
||
shapeOptions: orangeArea
|
||
},
|
||
polyline: {
|
||
metric: usesMetricMeasurements(),
|
||
feet: usesImperialMeasurements(),
|
||
showLength: true,
|
||
shapeOptions: {
|
||
pane: "drawingsPane",
|
||
renderer: lmeState.drawingsRenderer,
|
||
color: "#f97316",
|
||
weight: 4,
|
||
opacity: 0.95
|
||
}
|
||
},
|
||
rectangle: { metric: usesMetricMeasurements(), feet: usesImperialMeasurements(), shapeOptions: orangeArea }
|
||
}
|
||
});
|
||
lmeState.map.addControl(lmeState.drawControl);
|
||
installDrawingScopeControls();
|
||
bindDrawingEvents();
|
||
setInfo("Barra strumenti attiva: seleziona un disegno sulla mappa, poi usa matita o cestino.");
|
||
}
|
||
|
||
function installDrawingScopeControls() {
|
||
const toolbar = drawingEditToolbar();
|
||
const container = toolbar?._toolbarContainer;
|
||
if (!container) {
|
||
return;
|
||
}
|
||
const editButton = toolbar._modes?.[L.EditToolbar.Edit.TYPE]?.button;
|
||
editButton?.addEventListener("click", event => {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
showDrawingScopeMenu(editButton, "edit");
|
||
}, true);
|
||
const button = L.DomUtil.create("a", "leaflet-draw-edit-remove", container);
|
||
button.href = "#";
|
||
button.title = L.drawLocal.edit.toolbar.buttons.remove;
|
||
button.setAttribute("role", "button");
|
||
button.setAttribute("aria-label", L.drawLocal.edit.toolbar.buttons.remove);
|
||
const accessibleLabel = L.DomUtil.create("span", "sr-only", button);
|
||
accessibleLabel.textContent = L.drawLocal.edit.toolbar.buttons.remove;
|
||
L.DomEvent.on(button, "click", L.DomEvent.stop)
|
||
.on(button, "mousedown", L.DomEvent.stopPropagation)
|
||
.on(button, "touchstart", L.DomEvent.stopPropagation)
|
||
.on(button, "click", () => showDrawingScopeMenu(button, "delete"));
|
||
lmeState.drawingDeleteButton = button;
|
||
updateDrawingSelectionActions();
|
||
}
|
||
|
||
function showDrawingScopeMenu(button, action) {
|
||
if (drawingEditActive()) {
|
||
setInfo("Salva o annulla la modifica corrente prima di scegliere un altro comando.");
|
||
return;
|
||
}
|
||
if (!lmeState.drawings?.getLayers?.().length) {
|
||
setInfo("Non ci sono disegni o POI su cui operare.");
|
||
return;
|
||
}
|
||
if (lmeState.drawingScopeMenu?.dataset.action === action) {
|
||
hideDrawingScopeMenu();
|
||
return;
|
||
}
|
||
hideDrawingScopeMenu();
|
||
const menu = document.createElement("ul");
|
||
menu.className = "leaflet-draw-actions lme-drawing-scope-actions";
|
||
menu.dataset.action = action;
|
||
menu.style.display = "block";
|
||
menu.style.top = `${button.offsetTop - 1}px`;
|
||
menu.innerHTML = `<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;
|
||
}
|
||
if (!canAddDrawingObjects()) return;
|
||
bindDrawingEvents();
|
||
hideDrawingScopeMenu();
|
||
if (lmeState.activeDrawHandler) {
|
||
lmeState.activeDrawHandler.disable();
|
||
}
|
||
|
||
const handlers = {
|
||
area: () => new L.Draw.Polygon(lmeState.map, {
|
||
allowIntersection: false,
|
||
showArea: true,
|
||
metric: usesMetricMeasurements(),
|
||
feet: usesImperialMeasurements(),
|
||
shapeOptions: {
|
||
pane: "drawingsPane",
|
||
renderer: lmeState.drawingsRenderer,
|
||
color: "#f97316",
|
||
weight: 4,
|
||
fillColor: "#fb923c",
|
||
fillOpacity: 0.2
|
||
}
|
||
}),
|
||
distance: () => new L.Draw.Polyline(lmeState.map, {
|
||
metric: usesMetricMeasurements(),
|
||
feet: usesImperialMeasurements(),
|
||
showLength: true,
|
||
shapeOptions: {
|
||
pane: "drawingsPane",
|
||
renderer: lmeState.drawingsRenderer,
|
||
color: "#f97316",
|
||
weight: 4,
|
||
opacity: 0.95
|
||
}
|
||
}),
|
||
circle: () => new L.Draw.Circle(lmeState.map, {
|
||
metric: usesMetricMeasurements(),
|
||
feet: usesImperialMeasurements(),
|
||
showRadius: true,
|
||
shapeOptions: {
|
||
pane: "drawingsPane",
|
||
renderer: lmeState.drawingsRenderer,
|
||
color: "#f97316",
|
||
weight: 4,
|
||
fillColor: "#fb923c",
|
||
fillOpacity: 0.2
|
||
}
|
||
})
|
||
};
|
||
const createHandler = handlers[mode];
|
||
if (!createHandler) {
|
||
setInfo("Modalita di misura non riconosciuta.");
|
||
return;
|
||
}
|
||
|
||
lmeState.activeDrawHandler = createHandler();
|
||
lmeState.activeDrawHandler.enable();
|
||
const labels = {
|
||
area: "Tocca i vertici dell'area; chiudi toccando il primo punto.",
|
||
distance: "Tocca i punti del percorso; termina sull'ultimo punto.",
|
||
circle: "Tocca il centro e trascina fino al raggio desiderato."
|
||
};
|
||
setInfo(labels[mode]);
|
||
}
|
||
|
||
function annotateDrawingLayer(layer, layerType) {
|
||
layer.options.pane = "drawingsPane";
|
||
if (layer instanceof L.Path) {
|
||
layer.options.renderer = lmeState.drawingsRenderer;
|
||
}
|
||
layer.feature = layer.feature || { type: "Feature", properties: {} };
|
||
layer.feature.properties = layer.feature.properties || {};
|
||
if (layer instanceof L.Circle) {
|
||
layer.feature.properties._lmeShape = "circle";
|
||
layer.feature.properties._lmeRadius = layer.getRadius();
|
||
} else if (layerType) {
|
||
layer.feature.properties._lmeShape = layerType;
|
||
}
|
||
layer.lmeManagerLabel = drawingLayerLabel(layer, layerType);
|
||
bindDrawingLayerSelection(layer);
|
||
}
|
||
|
||
function bindDrawingLayerSelection(layer) {
|
||
if (layer.lmeDrawingSelectionBound) {
|
||
return;
|
||
}
|
||
layer.lmeDrawingSelectionBound = true;
|
||
layer.on("click", event => {
|
||
if (drawingEditActive()) {
|
||
return;
|
||
}
|
||
lmeState.ignoreMapClickUntil = Date.now() + 1000;
|
||
if (event.originalEvent) {
|
||
event.originalEvent._lmeDrawingSelected = true;
|
||
L.DomEvent.stopPropagation(event.originalEvent);
|
||
}
|
||
const id = managedLayerId(layer, "drawings");
|
||
if (event.originalEvent?.ctrlKey || event.originalEvent?.metaKey || event.originalEvent?.shiftKey) {
|
||
if (lmeState.managerSelectedObjectIds.has(id)) lmeState.managerSelectedObjectIds.delete(id);
|
||
else lmeState.managerSelectedObjectIds.add(id);
|
||
} else {
|
||
lmeState.managerSelectedObjectIds.clear();
|
||
lmeState.managerSelectedObjectIds.add(id);
|
||
}
|
||
selectDrawingLayer(layer);
|
||
refreshLayerManager();
|
||
window.requestAnimationFrame(() => revealManagedRecordInLayerManager(id));
|
||
});
|
||
}
|
||
|
||
function bindManagedMapSelection(layer, categoryKey, parent) {
|
||
if (layer.lmeManagedMapSelectionBound) return;
|
||
layer.lmeManagedMapSelectionBound = true;
|
||
layer.on("click", event => {
|
||
if (Date.now() < lmeState.ignoreMapClickUntil) return;
|
||
lmeState.ignoreMapClickUntil = Date.now() + 600;
|
||
if (event.originalEvent) {
|
||
event.originalEvent._lmeDrawingSelected = true;
|
||
L.DomEvent.stopPropagation(event.originalEvent);
|
||
}
|
||
const record = { id: managedLayerId(layer, categoryKey), categoryKey, layer, parent, source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente") };
|
||
const multiple = event.originalEvent?.ctrlKey || event.originalEvent?.metaKey || event.originalEvent?.shiftKey;
|
||
if (!multiple) lmeState.managerSelectedObjectIds.clear();
|
||
if (multiple && lmeState.managerSelectedObjectIds.has(record.id)) lmeState.managerSelectedObjectIds.delete(record.id);
|
||
else lmeState.managerSelectedObjectIds.add(record.id);
|
||
highlightManagedObject(record);
|
||
refreshLayerManager();
|
||
window.requestAnimationFrame(() => revealManagedRecordInLayerManager(record.id));
|
||
setInfo(`Selezionato dalla mappa: ${managedObjectEditableName(record) || "oggetto"}.`);
|
||
});
|
||
}
|
||
|
||
function setDrawToolsVisible(visible) {
|
||
if (visible) {
|
||
enableDrawTools();
|
||
return;
|
||
}
|
||
|
||
if (!lmeState.drawControl) {
|
||
return;
|
||
}
|
||
|
||
Object.values(lmeState.drawControl._toolbars || {}).forEach(toolbar => {
|
||
if (typeof toolbar.disable === "function") {
|
||
toolbar.disable();
|
||
}
|
||
});
|
||
lmeState.map.removeControl(lmeState.drawControl);
|
||
lmeState.drawControl = null;
|
||
lmeState.drawingDeleteButton = null;
|
||
lmeState.drawingScopeMenu = null;
|
||
setInfo("Strumenti di disegno nascosti.");
|
||
}
|
||
|
||
function clearDrawings() {
|
||
const count = managedGroupGeoJson(lmeState.drawings, "drawings").features.length;
|
||
if (!count) {
|
||
setInfo("Non ci sono disegni o POI da eliminare.");
|
||
return;
|
||
}
|
||
|
||
if (!window.confirm(`Eliminare ${count} disegni e POI dalla mappa?`)) {
|
||
return;
|
||
}
|
||
|
||
if (managedRecordsForCategory(layerManagerCategories().find(item => item.key === "drawings")).some(record => record.layer?.feature?.properties?._lmeLocked)) {
|
||
setInfo("Sblocca tutti i disegni prima di eliminarli."); return;
|
||
}
|
||
rememberMapChange();
|
||
drawingEditToolbar()?.disable?.();
|
||
lmeState.drawingSelection?.clearLayers();
|
||
clearManagerHighlight();
|
||
lmeState.drawings.clearLayers();
|
||
clearManagedCategoryState("drawings");
|
||
lmeState.drawingMeasurements.clearLayers();
|
||
updateGpsInsideState();
|
||
setInfo("Disegni e POI eliminati dalla mappa.");
|
||
refreshLayerManager();
|
||
finishMapChange();
|
||
}
|
||
|
||
function snapDrawingLayer(layer) {
|
||
if (!lmeState.snapEnabled || !layer || layer instanceof L.Circle || layer instanceof L.Marker) return;
|
||
const candidates = [];
|
||
const segments = [];
|
||
layerManagerCategories().forEach(category => managedRecordsForCategory(category).forEach(record => {
|
||
if (record.layer === layer) return;
|
||
lineLatLngGroups(record.layer?.getLatLngs?.() || []).forEach(line => {
|
||
line.forEach(point => candidates.push(point));
|
||
for (let index = 1; index < line.length; index++) segments.push([line[index - 1], line[index]]);
|
||
if (record.layer instanceof L.Polygon && line.length > 2) segments.push([line.at(-1), line[0]]);
|
||
});
|
||
const point = record.layer?.getLatLng?.(); if (point) candidates.push(point);
|
||
}));
|
||
const snapPoint = point => {
|
||
const pixel = lmeState.map.latLngToContainerPoint(point);
|
||
let nearest = null; let distance = lmeState.snapDistancePixels;
|
||
candidates.forEach(candidate => { const current = pixel.distanceTo(lmeState.map.latLngToContainerPoint(candidate)); if (current < distance) { distance = current; nearest = candidate; } });
|
||
segments.forEach(([start, end]) => {
|
||
const a = lmeState.map.latLngToContainerPoint(start); const b = lmeState.map.latLngToContainerPoint(end);
|
||
const lengthSquared = (b.x - a.x) ** 2 + (b.y - a.y) ** 2;
|
||
if (!lengthSquared) return;
|
||
const ratio = Math.max(0, Math.min(1, ((pixel.x - a.x) * (b.x - a.x) + (pixel.y - a.y) * (b.y - a.y)) / lengthSquared));
|
||
const projected = L.point(a.x + ratio * (b.x - a.x), a.y + ratio * (b.y - a.y));
|
||
const current = pixel.distanceTo(projected);
|
||
if (current < distance) { distance = current; nearest = lmeState.map.containerPointToLatLng(projected); }
|
||
const midpoint = L.point((a.x + b.x) / 2, (a.y + b.y) / 2);
|
||
const midpointDistance = pixel.distanceTo(midpoint);
|
||
if (midpointDistance < distance) { distance = midpointDistance; nearest = lmeState.map.containerPointToLatLng(midpoint); }
|
||
});
|
||
return nearest ? L.latLng(nearest.lat, nearest.lng) : point;
|
||
};
|
||
const transform = value => Array.isArray(value) ? value.map(transform) : snapPoint(value);
|
||
layer.setLatLngs(transform(layer.getLatLngs()));
|
||
}
|
||
|
||
function scheduleMeasurementRefresh() {
|
||
window.clearTimeout(lmeState.measurementRefreshTimer);
|
||
lmeState.measurementRefreshTimer = window.setTimeout(() => {
|
||
refreshImportedMeasurements();
|
||
refreshDrawingMeasurements();
|
||
}, 80);
|
||
}
|
||
|
||
function refreshImportedMeasurements() {
|
||
renderMeasurements(
|
||
lmeState.imported,
|
||
lmeState.importedMeasurements,
|
||
lmeState.showImportedMeasurements,
|
||
"imported");
|
||
}
|
||
|
||
function refreshDrawingMeasurements() {
|
||
renderMeasurements(
|
||
lmeState.drawings,
|
||
lmeState.drawingMeasurements,
|
||
lmeState.showDrawingMeasurements,
|
||
"drawing");
|
||
}
|
||
|
||
function renderMeasurements(sourceGroup, targetGroup, visible, sourceType) {
|
||
if (!sourceGroup || !targetGroup) {
|
||
return;
|
||
}
|
||
targetGroup.clearLayers();
|
||
if (!visible || !lmeState.map.hasLayer(sourceGroup)) {
|
||
return;
|
||
}
|
||
|
||
const context = {
|
||
count: 0,
|
||
max: 600,
|
||
showSides: lmeState.map.getZoom() >= 17,
|
||
bounds: lmeState.map.getBounds(),
|
||
sourceType
|
||
};
|
||
visitMeasurableLayers(sourceGroup, layer => {
|
||
const categoryKey = sourceType === "imported" ? "imported" : "drawings";
|
||
const objectId = managedLayerId(layer, categoryKey);
|
||
const showSummary = !lmeState.managerHiddenMeasurementSummaryIds.has(objectId);
|
||
const showSides = !lmeState.managerHiddenMeasurementSideIds.has(objectId);
|
||
if (!showSummary && !showSides) {
|
||
return;
|
||
}
|
||
if (context.count >= context.max || !layerIsInView(layer, context.bounds)) {
|
||
return;
|
||
}
|
||
const layerContext = {
|
||
...context,
|
||
showSummary,
|
||
showSides: context.showSides && showSides
|
||
};
|
||
if (layer instanceof L.Circle) {
|
||
renderCircleMeasurement(layer, targetGroup, layerContext);
|
||
} else if (layer instanceof L.Polygon) {
|
||
renderPolygonMeasurement(layer, targetGroup, layerContext);
|
||
} else if (layer instanceof L.Polyline) {
|
||
renderPolylineMeasurement(layer, targetGroup, layerContext);
|
||
}
|
||
});
|
||
}
|
||
|
||
function visitMeasurableLayers(layer, visitor) {
|
||
if (layer instanceof L.Circle || layer instanceof L.Polygon || layer instanceof L.Polyline) {
|
||
visitor(layer);
|
||
return;
|
||
}
|
||
if (typeof layer.eachLayer === "function") {
|
||
layer.eachLayer(child => visitMeasurableLayers(child, visitor));
|
||
}
|
||
}
|
||
|
||
function layerIsInView(layer, mapBounds) {
|
||
if (typeof layer.getBounds === "function") {
|
||
const bounds = layer.getBounds();
|
||
return !bounds.isValid || !bounds.isValid() || bounds.intersects(mapBounds);
|
||
}
|
||
if (typeof layer.getLatLng === "function") {
|
||
return mapBounds.contains(layer.getLatLng());
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function renderCircleMeasurement(layer, targetGroup, context) {
|
||
if (!context.showSummary) {
|
||
return;
|
||
}
|
||
const radius = layer.getRadius();
|
||
const area = Math.PI * radius * radius;
|
||
const circumference = 2 * Math.PI * radius;
|
||
addMeasurementLabel(
|
||
targetGroup,
|
||
layer.getLatLng(),
|
||
`S: ${formatArea(area)}<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 (usesImperialMeasurements()) {
|
||
const feet = meters * 3.280839895;
|
||
if (feet >= 5280) {
|
||
return `${formatNumber(feet / 5280, 3)} mi`;
|
||
}
|
||
return `${formatNumber(feet, feet < 100 ? 1 : 0)} ft`;
|
||
}
|
||
|
||
if (meters >= 1000) {
|
||
return `${formatNumber(meters / 1000, 3)} km`;
|
||
}
|
||
return `${formatNumber(meters, meters < 100 ? 1 : 0)} m`;
|
||
}
|
||
|
||
function formatArea(squareMeters) {
|
||
if (usesImperialMeasurements()) {
|
||
const squareFeet = squareMeters * 10.763910417;
|
||
const acres = squareMeters * 0.000247105381;
|
||
if (acres >= 1) {
|
||
return `${formatNumber(acres, 3)} ac`;
|
||
}
|
||
return `${formatNumber(squareFeet, squareFeet < 100 ? 1 : 0)} ft²`;
|
||
}
|
||
|
||
if (squareMeters >= 10000) {
|
||
return `${formatNumber(squareMeters / 10000, 4)} ha`;
|
||
}
|
||
return `${formatNumber(squareMeters, squareMeters < 100 ? 1 : 0)} m²`;
|
||
}
|
||
|
||
function formatNumber(value, digits) {
|
||
return new Intl.NumberFormat(lmeState.culture || "it-IT", {
|
||
minimumFractionDigits: 0,
|
||
maximumFractionDigits: digits
|
||
}).format(value);
|
||
}
|
||
|
||
function usesMetricMeasurements() {
|
||
return lmeState.measurementSystem !== "imperial";
|
||
}
|
||
|
||
function usesImperialMeasurements() {
|
||
return lmeState.measurementSystem === "imperial";
|
||
}
|
||
|
||
function createMapExportGeoJson(options) {
|
||
migrateMisclassifiedImportedSheets();
|
||
const requested = options || {};
|
||
const features = [];
|
||
|
||
if (requested.includeImported !== false) {
|
||
appendExportFeatures(features, managedGroupGeoJson(lmeState.imported, "imported"), "imported");
|
||
}
|
||
if (requested.includeImportedSheets !== false) {
|
||
appendExportFeatures(features, managedGroupGeoJson(lmeState.importedSheets, "importedSheets"), "imported-sheet");
|
||
}
|
||
if (requested.includeCadastral !== false) {
|
||
appendExportFeatures(features, managedGroupGeoJson(lmeState.selectedParcelsLayer, "selectedParcels"), "cadastral");
|
||
appendExportFeatures(features, managedGroupGeoJson(lmeState.gpsParcelLayer, "gpsParcels"), "cadastral-gps");
|
||
}
|
||
|
||
const drawings = managedGroupGeoJson(lmeState.drawings, "drawings");
|
||
if (requested.includeDrawings !== false) {
|
||
appendExportFeatures(features, drawings, "drawing", feature => !isPoiFeature(feature));
|
||
}
|
||
if (requested.includePoi !== false) {
|
||
appendExportFeatures(features, drawings, "poi", isPoiFeature);
|
||
}
|
||
if (requested.includeTrail !== false && lmeState.gpsTrailPoints.length >= 2) {
|
||
features.push({
|
||
type: "Feature",
|
||
properties: { name: "Traccia GPS", _lmeCategory: "gps-trail" },
|
||
geometry: {
|
||
type: "LineString",
|
||
coordinates: lmeState.gpsTrailPoints.map(point => [
|
||
Number(point.lng ?? point[1]),
|
||
Number(point.lat ?? point[0])
|
||
])
|
||
}
|
||
});
|
||
}
|
||
|
||
const exported = requested.selectedOnly
|
||
? features.filter(feature => lmeState.managerSelectedObjectIds.has(feature?.properties?._lmeManagerId))
|
||
: features;
|
||
return JSON.stringify({ type: "FeatureCollection", features: exported });
|
||
}
|
||
|
||
function appendExportFeatures(target, collection, category, predicate = () => true) {
|
||
for (const sourceFeature of collection?.features || []) {
|
||
if (!sourceFeature?.geometry || !predicate(sourceFeature)) {
|
||
continue;
|
||
}
|
||
const feature = JSON.parse(JSON.stringify(sourceFeature));
|
||
feature.properties = { ...(feature.properties || {}), _lmeCategory: category };
|
||
if (feature.properties._lmeShape === "circle"
|
||
&& feature.geometry.type === "Point"
|
||
&& Number(feature.properties._lmeRadius) > 0) {
|
||
feature.geometry = circleToPolygon(feature.geometry.coordinates, Number(feature.properties._lmeRadius));
|
||
}
|
||
target.push(feature);
|
||
}
|
||
}
|
||
|
||
function isPoiFeature(feature) {
|
||
return feature?.geometry?.type === "Point"
|
||
&& feature?.properties?._lmeShape !== "circle";
|
||
}
|
||
|
||
function circleToPolygon(center, radiusMeters) {
|
||
const longitude = Number(center?.[0]);
|
||
const latitude = Number(center?.[1]);
|
||
const earthRadius = 6378137;
|
||
const latitudeRadians = latitude * Math.PI / 180;
|
||
const ring = [];
|
||
for (let index = 0; index <= 64; index += 1) {
|
||
const angle = index / 64 * Math.PI * 2;
|
||
const north = Math.sin(angle) * radiusMeters;
|
||
const east = Math.cos(angle) * radiusMeters;
|
||
ring.push([
|
||
longitude + east / (earthRadius * Math.cos(latitudeRadians)) * 180 / Math.PI,
|
||
latitude + north / earthRadius * 180 / Math.PI
|
||
]);
|
||
}
|
||
return { type: "Polygon", coordinates: [ring] };
|
||
}
|
||
|
||
function createProjectSnapshot(includeCadastralParcels) {
|
||
const center = lmeState.map.getCenter();
|
||
return JSON.stringify({
|
||
schemaVersion: 6,
|
||
view: {
|
||
latitude: center.lat,
|
||
longitude: center.lng,
|
||
zoom: lmeState.map.getZoom()
|
||
},
|
||
drawings: managedGroupGeoJson(lmeState.drawings, "drawings"),
|
||
imported: managedGroupGeoJson(lmeState.imported, "imported"),
|
||
importedSheets: managedGroupGeoJson(lmeState.importedSheets, "importedSheets"),
|
||
importedSheetsVisible: lmeState.importedSheetsVisible,
|
||
fiducials: managedGroupGeoJson(lmeState.fiducials, "fiducials"),
|
||
displayOptions: captureDisplayOptions(),
|
||
selectedParcels: includeCadastralParcels
|
||
? managedGroupGeoJson(lmeState.selectedParcelsLayer, "selectedParcels")
|
||
: { type: "FeatureCollection", features: [] },
|
||
trail: lmeState.gpsTrailPoints.map(point => [
|
||
Number(point.lat ?? point[0]),
|
||
Number(point.lng ?? point[1])
|
||
]),
|
||
trailStartedAt: lmeState.gpsTrailStartedAt,
|
||
auditLog: [...lmeState.auditLog]
|
||
});
|
||
}
|
||
|
||
function getLegacyProjectSnapshot() {
|
||
return localStorage.getItem("lmeMapWorkspace");
|
||
}
|
||
|
||
function clearLegacyProjectSnapshot() {
|
||
localStorage.removeItem("lmeMapWorkspace");
|
||
}
|
||
|
||
function loadProjectSnapshot(raw, projectName) {
|
||
try {
|
||
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||
if (!data || typeof data !== "object") {
|
||
throw new Error("contenuto non valido");
|
||
}
|
||
|
||
resetLayerManagerProjectState();
|
||
lmeState.drawings.clearLayers();
|
||
lmeState.imported.clearLayers();
|
||
lmeState.importedSheets.clearLayers();
|
||
lmeState.drawingMeasurements.clearLayers();
|
||
lmeState.importedMeasurements.clearLayers();
|
||
lmeState.selectedParcelsLayer.clearLayers();
|
||
lmeState.fiducials.clearLayers();
|
||
lmeState.gpsParcelLayer.clearLayers();
|
||
|
||
const restoredDrawings = L.geoJSON(data.drawings || emptyFeatureCollection(), {
|
||
pane: "drawingsPane",
|
||
style: storedFeatureStyle,
|
||
pointToLayer: (feature, latlng) => {
|
||
const properties = feature?.properties || {};
|
||
if (properties._lmeShape === "circle" && Number(properties._lmeRadius) > 0) {
|
||
return L.circle(latlng, {
|
||
pane: "drawingsPane",
|
||
renderer: lmeState.drawingsRenderer,
|
||
radius: Number(properties._lmeRadius),
|
||
color: "#f97316",
|
||
weight: 3,
|
||
fillColor: "#fb923c",
|
||
fillOpacity: 0.2
|
||
});
|
||
}
|
||
return L.marker(latlng, { pane: "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);
|
||
applyImportedSheetVisibility();
|
||
L.geoJSON(data.selectedParcels || emptyFeatureCollection(), {
|
||
pane: "selectedParcelsPane",
|
||
style: feature => ({
|
||
color: feature?.properties?._lmeColor || "#ef4444",
|
||
weight: Number(feature?.properties?._lmeWeight) || 4,
|
||
opacity: Number.isFinite(Number(feature?.properties?._lmeOpacity)) ? Number(feature.properties._lmeOpacity) : 1,
|
||
fillColor: feature?.properties?._lmeFillColor || "#fbbf24",
|
||
fillOpacity: Number.isFinite(Number(feature?.properties?._lmeFillOpacity)) ? Number(feature.properties._lmeFillOpacity) : 0.26,
|
||
dashArray: feature?.properties?._lmeDash || null
|
||
}),
|
||
onEachFeature: (feature, layer) => {
|
||
const properties = feature?.properties || {};
|
||
const reference = properties.NATIONALCADASTRALREFERENCE
|
||
|| properties.INSPIREID_LOCALID
|
||
|| properties.LABEL
|
||
|| "Particella catastale";
|
||
layer.lmeParcelReference = reference;
|
||
layer.bindPopup(`<strong>${escapeHtml(reference)}</strong>`);
|
||
applyStyleFromProperties(layer);
|
||
}
|
||
}).addTo(lmeState.selectedParcelsLayer);
|
||
L.geoJSON(data.fiducials || emptyFeatureCollection(), {
|
||
pointToLayer: (feature, latlng) => createFiducialMarker(feature, latlng),
|
||
onEachFeature: (feature, layer) => bindFiducialPopup(feature, layer)
|
||
}).addTo(lmeState.fiducials);
|
||
if (lmeState.importedSheetsVisible) {
|
||
void ensureImportedSheetOutlines().then(() => {
|
||
applyImportedSheetVisibility();
|
||
refreshLayerManager();
|
||
});
|
||
}
|
||
|
||
lmeState.gpsTrailPoints = Array.isArray(data.trail) ? data.trail : [];
|
||
lmeState.gpsTrailStartedAt = data.trailStartedAt || null;
|
||
lmeState.auditLog = Array.isArray(data.auditLog) ? data.auditLog.slice(-250) : [];
|
||
lmeState.gpsTrail.setLatLngs(lmeState.gpsTrailPoints);
|
||
if (projectDisplayOptions) {
|
||
applyProjectDisplayOptions(projectDisplayOptions);
|
||
}
|
||
refreshImportedMeasurements();
|
||
refreshDrawingMeasurements();
|
||
refreshPermanentLabels();
|
||
|
||
const projectLayerList = [
|
||
lmeState.drawings,
|
||
lmeState.imported,
|
||
lmeState.selectedParcelsLayer,
|
||
lmeState.fiducials,
|
||
lmeState.gpsTrail
|
||
];
|
||
if (lmeState.importedSheetsVisible) {
|
||
projectLayerList.push(lmeState.importedSheets);
|
||
}
|
||
const projectLayers = L.featureGroup(projectLayerList);
|
||
if (projectLayers.getBounds().isValid()) {
|
||
fitLayer(projectLayers);
|
||
} else if (Number.isFinite(data.view?.latitude)
|
||
&& Number.isFinite(data.view?.longitude)
|
||
&& Number.isFinite(data.view?.zoom)) {
|
||
lmeState.map.setView([data.view.latitude, data.view.longitude], data.view.zoom);
|
||
}
|
||
|
||
const parcelCount = lmeState.selectedParcelsLayer.toGeoJSON().features?.length || 0;
|
||
setText("parcelState", parcelCount
|
||
? `${parcelCount} particelle del progetto`
|
||
: "Nessuna particella selezionata");
|
||
updateGpsInsideState();
|
||
const hiddenSheets = importedParts.sheets.features.length && !lmeState.importedSheetsVisible
|
||
? ` ${importedParts.sheets.features.length} elementi del foglio sono nascosti.`
|
||
: "";
|
||
setInfo(`Progetto caricato: ${projectName || "senza nome"}.${hiddenSheets}`);
|
||
refreshLayerManager();
|
||
if (!lmeState.historyBusy) {
|
||
lmeState.isDirty = false;
|
||
lmeState.undoStack = [];
|
||
lmeState.redoStack = [];
|
||
void lmeState.dotNetReference?.invokeMethodAsync("OnProjectSaved");
|
||
}
|
||
return captureDisplayOptions();
|
||
} catch (error) {
|
||
setInfo(`Progetto non leggibile: ${error.message}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function captureDisplayOptions() {
|
||
return {
|
||
baseLayer: lmeState.currentBaseName,
|
||
cadastreOpacity: lmeState.cadastreOpacity,
|
||
cadastreLayers: [...lmeState.cadastreLayers],
|
||
includeKmlSheetLayer: lmeState.includeKmlSheetLayer,
|
||
importedVisible: lmeState.importedVisible,
|
||
importedSheetsVisible: lmeState.importedSheetsVisible,
|
||
showImportedMeasurements: lmeState.showImportedMeasurements,
|
||
showDrawingMeasurements: lmeState.showDrawingMeasurements,
|
||
drawToolsVisible: Boolean(lmeState.drawControl),
|
||
gpsPositionVisible: lmeState.gpsPositionVisible,
|
||
selectedParcelsVisible: lmeState.selectedParcelsVisible,
|
||
fiducialsVisible: lmeState.fiducialsVisible,
|
||
fiducialColor: lmeState.fiducialColor,
|
||
layerOrder: [...lmeState.layerOrder],
|
||
layerObjectOrder: { ...lmeState.managerObjectOrder },
|
||
layerCategoryNames: { ...lmeState.managerCategoryNames },
|
||
removedLayerCategories: [...lmeState.managerRemovedCategories],
|
||
hiddenLayerObjects: [...lmeState.managerHiddenObjectIds],
|
||
hiddenLayerMeasurementSummaries: [...lmeState.managerHiddenMeasurementSummaryIds],
|
||
hiddenLayerMeasurementSides: [...lmeState.managerHiddenMeasurementSideIds],
|
||
hiddenLayerCategories: [...lmeState.managerHiddenCategories],
|
||
trailEnabled: lmeState.gpsTrailEnabled
|
||
};
|
||
}
|
||
|
||
function applyProjectDisplayOptions(options) {
|
||
const supportedCadastreLayers = new Set([
|
||
"CP.CadastralParcel",
|
||
"codice_plla",
|
||
"CP.CadastralZoning",
|
||
"fabbricati",
|
||
"strade",
|
||
"acque"
|
||
]);
|
||
if (Array.isArray(options.cadastreLayers)) {
|
||
lmeState.cadastreLayers = options.cadastreLayers.filter(layer => supportedCadastreLayers.has(layer));
|
||
}
|
||
|
||
const opacity = Number(options.cadastreOpacity);
|
||
if (Number.isFinite(opacity)) {
|
||
lmeState.cadastreOpacity = Math.max(0, Math.min(1, opacity));
|
||
}
|
||
lmeState.includeKmlSheetLayer = Boolean(options.includeKmlSheetLayer);
|
||
lmeState.importedVisible = options.importedVisible !== false;
|
||
lmeState.importedSheetsVisible = Boolean(options.importedSheetsVisible);
|
||
lmeState.showImportedMeasurements = Boolean(options.showImportedMeasurements);
|
||
lmeState.showDrawingMeasurements = options.showDrawingMeasurements !== false;
|
||
if (Array.isArray(options.layerOrder)) {
|
||
setLayerOrder(options.layerOrder);
|
||
}
|
||
if (options.layerObjectOrder && typeof options.layerObjectOrder === "object") {
|
||
lmeState.managerObjectOrder = options.layerObjectOrder;
|
||
}
|
||
if (options.layerCategoryNames && typeof options.layerCategoryNames === "object") {
|
||
lmeState.managerCategoryNames = { ...options.layerCategoryNames };
|
||
}
|
||
if (Array.isArray(options.removedLayerCategories)) {
|
||
lmeState.managerRemovedCategories = new Set(options.removedLayerCategories);
|
||
}
|
||
if (Array.isArray(options.hiddenLayerObjects)) {
|
||
lmeState.managerHiddenObjectIds = new Set(options.hiddenLayerObjects);
|
||
}
|
||
if (Array.isArray(options.hiddenLayerMeasurementSummaries)) {
|
||
lmeState.managerHiddenMeasurementSummaryIds = new Set(options.hiddenLayerMeasurementSummaries);
|
||
}
|
||
if (Array.isArray(options.hiddenLayerMeasurementSides)) {
|
||
lmeState.managerHiddenMeasurementSideIds = new Set(options.hiddenLayerMeasurementSides);
|
||
}
|
||
if (Array.isArray(options.hiddenLayerCategories)) {
|
||
lmeState.managerHiddenCategories = new Set(options.hiddenLayerCategories);
|
||
}
|
||
applyImportedSheetVisibility();
|
||
setImportedLayerVisible(lmeState.importedVisible);
|
||
refreshCadastreLayer();
|
||
|
||
if (typeof options.baseLayer === "string") {
|
||
setBaseLayer(options.baseLayer);
|
||
}
|
||
setGpsPositionVisible(options.gpsPositionVisible !== false);
|
||
setSelectedParcelsVisible(options.selectedParcelsVisible !== false);
|
||
if (typeof options.fiducialColor === "string") {
|
||
setFiducialColor(options.fiducialColor);
|
||
}
|
||
setFiducialPointsVisible(options.fiducialsVisible !== false);
|
||
toggleTrail(Boolean(options.trailEnabled));
|
||
setDrawToolsVisible(Boolean(options.drawToolsVisible));
|
||
applyStoredLayerManagerVisibility();
|
||
saveLayerManagerState();
|
||
}
|
||
|
||
function partitionStoredImportedFeatures(imported, storedSheets) {
|
||
const parcelFeatures = [];
|
||
const sheetFeatures = [...(storedSheets?.features || [])];
|
||
const sourceGroups = new Map();
|
||
|
||
(imported?.features || []).forEach(feature => {
|
||
if (isSheetOrOverlayFeature(feature)) {
|
||
sheetFeatures.push(feature);
|
||
return;
|
||
}
|
||
const source = String(feature?.properties?._lmeSource || "");
|
||
if (!/\.(kml|kmz)$/i.test(source)) {
|
||
parcelFeatures.push(feature);
|
||
return;
|
||
}
|
||
const group = sourceGroups.get(source) || [];
|
||
group.push(feature);
|
||
sourceGroups.set(source, group);
|
||
});
|
||
|
||
sourceGroups.forEach(features => {
|
||
const polygonParts = splitKmlPolygonFeatures(features);
|
||
const parcels = filterKmlParcelFeatures(polygonParts);
|
||
const parcelSet = new Set(parcels);
|
||
parcelFeatures.push(...parcels);
|
||
sheetFeatures.push(...polygonParts.filter(feature => !parcelSet.has(feature)));
|
||
sheetFeatures.push(...features.filter(feature => geometryPolygons(feature.geometry).length === 0));
|
||
});
|
||
|
||
return {
|
||
parcels: { type: "FeatureCollection", features: parcelFeatures },
|
||
sheets: { type: "FeatureCollection", features: sheetFeatures }
|
||
};
|
||
}
|
||
|
||
function emptyFeatureCollection() {
|
||
return { type: "FeatureCollection", features: [] };
|
||
}
|
||
|
||
function managedGroupGeoJson(group, categoryKey) {
|
||
const collection = group?.toGeoJSON?.() || emptyFeatureCollection();
|
||
const features = [...(collection.features || [])];
|
||
const categoryName = lmeState.managerCategoryNames[categoryKey];
|
||
if (categoryName) {
|
||
features.forEach(feature => {
|
||
feature.properties = { ...(feature.properties || {}), _lmeCategoryName: categoryName };
|
||
});
|
||
}
|
||
return { type: "FeatureCollection", features };
|
||
}
|
||
|
||
function resetLayerManagerProjectState() {
|
||
drawingEditToolbar()?.disable?.();
|
||
lmeState.drawingSelection?.clearLayers();
|
||
lmeState.managerHiddenObjects.clear();
|
||
lmeState.managerHiddenObjectIds.clear();
|
||
lmeState.managerHiddenMeasurementSummaryIds.clear();
|
||
lmeState.managerHiddenMeasurementSideIds.clear();
|
||
lmeState.managerHiddenCategories.clear();
|
||
lmeState.managerRemovedCategories.clear();
|
||
lmeState.managerObjectOrder = {};
|
||
lmeState.managerCategoryNames = {};
|
||
lmeState.managerSelectedObjectIds.clear();
|
||
lmeState.permanentLabels?.clearLayers();
|
||
clearManagerHighlight();
|
||
}
|
||
|
||
function clearManagedCategoryState(categoryKey) {
|
||
const category = layerManagerCategories().find(item => item.key === categoryKey);
|
||
if (category) {
|
||
managedRecordsForCategory(category).forEach(record => {
|
||
lmeState.managerHiddenMeasurementSummaryIds.delete(record.id);
|
||
lmeState.managerHiddenMeasurementSideIds.delete(record.id);
|
||
});
|
||
}
|
||
[...lmeState.managerHiddenObjects.entries()].forEach(([id, record]) => {
|
||
if (record.categoryKey === categoryKey) {
|
||
lmeState.managerHiddenObjects.delete(id);
|
||
lmeState.managerHiddenObjectIds.delete(id);
|
||
}
|
||
});
|
||
lmeState.managerHiddenCategories.delete(categoryKey);
|
||
delete lmeState.managerObjectOrder[categoryKey];
|
||
}
|
||
|
||
function bindStoredFeaturePopup(feature, layer) {
|
||
updateStoredFeaturePopup(layer, feature?.properties);
|
||
}
|
||
|
||
function updateStoredFeaturePopup(layer, sourceProperties) {
|
||
const properties = sourceProperties || layer?.feature?.properties || {};
|
||
const label = properties.name || properties.Name || properties.label || properties.LABEL;
|
||
const notes = String(properties._lmeNotes || "").trim();
|
||
const photo = String(properties._lmePhoto || "");
|
||
if (label || notes || photo) {
|
||
const image = photo.startsWith("data:image/") ? `<img class="lme-popup-photo" src="${photo}" alt="Foto allegata">` : "";
|
||
layer.bindPopup(`${label ? `<strong>${escapeHtml(label)}</strong>` : ""}${notes ? `<p>${escapeHtml(notes)}</p>` : ""}${image}`);
|
||
return;
|
||
}
|
||
if (typeof layer?.unbindPopup === "function") {
|
||
layer.unbindPopup();
|
||
}
|
||
}
|
||
|
||
function offlineDatabase() {
|
||
if (lmeState.offlineDbPromise) return lmeState.offlineDbPromise;
|
||
lmeState.offlineDbPromise = new Promise((resolve, reject) => {
|
||
const request = indexedDB.open("lmeOfflineMaps", 1);
|
||
request.onupgradeneeded = () => request.result.createObjectStore("tiles", { keyPath: "key" });
|
||
request.onsuccess = () => resolve(request.result);
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
return lmeState.offlineDbPromise;
|
||
}
|
||
|
||
async function offlineTileGet(key) {
|
||
const db = await offlineDatabase();
|
||
return new Promise((resolve, reject) => {
|
||
const request = db.transaction("tiles", "readonly").objectStore("tiles").get(key);
|
||
request.onsuccess = () => resolve(request.result || null);
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
}
|
||
|
||
async function offlineTilePut(value) {
|
||
const db = await offlineDatabase();
|
||
return new Promise((resolve, reject) => {
|
||
const request = db.transaction("tiles", "readwrite").objectStore("tiles").put(value);
|
||
request.onsuccess = () => resolve();
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
}
|
||
|
||
function createOfflineTileLayer() {
|
||
const layer = L.gridLayer({ attribution: "Tessere salvate offline", maxZoom: 22 });
|
||
layer.createTile = (coords, done) => {
|
||
const tile = document.createElement("img");
|
||
tile.alt = "";
|
||
tile.className = "lme-offline-image-tile";
|
||
offlineTileGet(`${coords.z}/${coords.x}/${coords.y}`).then(record => {
|
||
if (!record?.blob) {
|
||
tile.className = "lme-offline-tile";
|
||
done(null, tile);
|
||
return;
|
||
}
|
||
const url = URL.createObjectURL(record.blob);
|
||
tile.onload = () => { URL.revokeObjectURL(url); done(null, tile); };
|
||
tile.onerror = error => { URL.revokeObjectURL(url); done(error, tile); };
|
||
tile.src = url;
|
||
}).catch(error => done(error, tile));
|
||
return tile;
|
||
};
|
||
return layer;
|
||
}
|
||
|
||
function offlineSourceUrl(provider, z, x, y) {
|
||
const urls = {
|
||
osm: `https://a.tile.openstreetmap.org/${z}/${x}/${y}.png`,
|
||
satellite: `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${z}/${y}/${x}`,
|
||
topographic: `https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/${z}/${y}/${x}`,
|
||
"streets-esri": `https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/${z}/${y}/${x}`,
|
||
terrain: `https://a.tile.opentopomap.org/${z}/${x}/${y}.png`,
|
||
carto: `https://a.basemaps.cartocdn.com/rastertiles/voyager/${z}/${x}/${y}.png`
|
||
};
|
||
return urls[provider] || null;
|
||
}
|
||
|
||
function longitudeToTileX(longitude, zoom) {
|
||
return Math.floor((longitude + 180) / 360 * 2 ** zoom);
|
||
}
|
||
|
||
function latitudeToTileY(latitude, zoom) {
|
||
const value = Math.max(-85.0511, Math.min(85.0511, latitude)) * Math.PI / 180;
|
||
return Math.floor((1 - Math.log(Math.tan(value) + 1 / Math.cos(value)) / Math.PI) / 2 * 2 ** zoom);
|
||
}
|
||
|
||
async function downloadOfflineArea(extraZoomLevels = 2) {
|
||
const provider = lmeState.currentBaseName;
|
||
if (!offlineSourceUrl(provider, 0, 0, 0)) {
|
||
throw new Error("Per il download offline usa prima uno sfondo Esri, OpenStreetMap, OpenTopoMap o CARTO.");
|
||
}
|
||
const bounds = lmeState.map.getBounds();
|
||
const startZoom = Math.max(1, lmeState.map.getZoom());
|
||
const endZoom = Math.min(19, startZoom + Math.max(0, Math.min(4, Number(extraZoomLevels) || 0)));
|
||
const tiles = [];
|
||
for (let z = startZoom; z <= endZoom; z++) {
|
||
const max = 2 ** z - 1;
|
||
const x1 = Math.max(0, Math.min(max, longitudeToTileX(bounds.getWest(), z)));
|
||
const x2 = Math.max(0, Math.min(max, longitudeToTileX(bounds.getEast(), z)));
|
||
const y1 = Math.max(0, Math.min(max, latitudeToTileY(bounds.getNorth(), z)));
|
||
const y2 = Math.max(0, Math.min(max, latitudeToTileY(bounds.getSouth(), z)));
|
||
for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) {
|
||
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) tiles.push({ z, x, y });
|
||
}
|
||
}
|
||
if (tiles.length > 600) throw new Error(`Area troppo ampia (${tiles.length} tessere). Aumenta lo zoom o riduci la zona visibile.`);
|
||
let saved = 0;
|
||
for (const tile of tiles) {
|
||
const key = `${tile.z}/${tile.x}/${tile.y}`;
|
||
if (await offlineTileGet(key)) { saved++; continue; }
|
||
const response = await fetch(offlineSourceUrl(provider, tile.z, tile.x, tile.y), { mode: "cors" });
|
||
if (!response.ok) continue;
|
||
await offlineTilePut({ key, blob: await response.blob(), provider, savedAt: new Date().toISOString() });
|
||
saved++;
|
||
if (saved % 10 === 0) setInfo(`Mappe offline: ${saved}/${tiles.length} tessere salvate...`);
|
||
}
|
||
lmeState.offlineTileCount = await getOfflineTileStats();
|
||
lmeState.baseLayers.offline.redraw();
|
||
setInfo(`Area offline pronta: ${saved} tessere, zoom ${startZoom}-${endZoom}.`);
|
||
return { saved, requested: tiles.length, startZoom, endZoom };
|
||
}
|
||
|
||
async function getOfflineTileStats() {
|
||
const db = await offlineDatabase();
|
||
return new Promise((resolve, reject) => {
|
||
const request = db.transaction("tiles", "readonly").objectStore("tiles").count();
|
||
request.onsuccess = () => resolve(request.result || 0);
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
}
|
||
|
||
async function clearOfflineTiles() {
|
||
const db = await offlineDatabase();
|
||
await new Promise((resolve, reject) => {
|
||
const request = db.transaction("tiles", "readwrite").objectStore("tiles").clear();
|
||
request.onsuccess = () => resolve();
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
lmeState.offlineTileCount = 0;
|
||
lmeState.baseLayers.offline.redraw();
|
||
setInfo("Archivio mappe offline svuotato.");
|
||
return true;
|
||
}
|
||
|
||
function setBaseLayer(name) {
|
||
const selected = lmeState.baseLayers[name] || lmeState.baseLayers.satellite;
|
||
if (lmeState.currentBase) {
|
||
lmeState.map.removeLayer(lmeState.currentBase);
|
||
}
|
||
lmeState.currentBase = selected.addTo(lmeState.map);
|
||
lmeState.currentBaseName = selected === lmeState.baseLayers[name] ? name : "satellite";
|
||
lmeState.currentBase.bringToBack();
|
||
const labels = {
|
||
satellite: "Satellite Esri",
|
||
osm: "OpenStreetMap",
|
||
"streets-esri": "Strade dettagliate Esri",
|
||
topographic: "Topografica Esri",
|
||
terrain: "OpenTopoMap",
|
||
carto: "CARTO Voyager",
|
||
offline: "Mappe salvate offline",
|
||
"azure-road": "Strade Azure Maps",
|
||
"azure-satellite": "Satellite Azure Maps",
|
||
"google-satellite": "Satellite Google Maps",
|
||
"google-roadmap": "Strade Google Maps"
|
||
};
|
||
setInfo(`Sfondo attivo: ${labels[name] || labels.satellite}. Il catasto AdE resta sovrapposto.`);
|
||
if (lmeState.currentBaseName.startsWith("azure-")) {
|
||
void updateAzureAttribution();
|
||
} else {
|
||
clearAzureAttribution();
|
||
}
|
||
}
|
||
|
||
function configureGoogleMaps(apiKey) {
|
||
const key = (apiKey || "").trim();
|
||
if (!key) {
|
||
return Promise.reject(new Error("Chiave Google Maps mancante."));
|
||
}
|
||
|
||
if (window.google?.maps && L.gridLayer.googleMutant) {
|
||
createGoogleLayers();
|
||
return Promise.resolve();
|
||
}
|
||
|
||
if (lmeState.googleMapsLoading) {
|
||
return lmeState.googleMapsLoading;
|
||
}
|
||
|
||
lmeState.googleMapsLoading = new Promise((resolve, reject) => {
|
||
const callbackName = `lmeGoogleMapsReady${Date.now()}`;
|
||
const script = document.createElement("script");
|
||
window[callbackName] = () => {
|
||
delete window[callbackName];
|
||
createGoogleLayers();
|
||
resolve();
|
||
};
|
||
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&loading=async&callback=${callbackName}`;
|
||
script.async = true;
|
||
script.onerror = () => {
|
||
delete window[callbackName];
|
||
lmeState.googleMapsLoading = null;
|
||
reject(new Error("Impossibile caricare Google Maps. Controlla chiave, API e connessione."));
|
||
};
|
||
document.head.appendChild(script);
|
||
});
|
||
|
||
return lmeState.googleMapsLoading;
|
||
}
|
||
|
||
function createGoogleLayers() {
|
||
if (!L.gridLayer.googleMutant) {
|
||
throw new Error("Modulo Google Maps non disponibile.");
|
||
}
|
||
|
||
lmeState.baseLayers["google-satellite"] ||= L.gridLayer.googleMutant({ type: "hybrid", maxZoom: 22 });
|
||
lmeState.baseLayers["google-roadmap"] ||= L.gridLayer.googleMutant({ type: "roadmap", maxZoom: 22 });
|
||
}
|
||
|
||
function configureAzureMaps(subscriptionKey) {
|
||
const key = (subscriptionKey || "").trim();
|
||
if (!key) {
|
||
throw new Error("Chiave Azure Maps mancante.");
|
||
}
|
||
if (lmeState.azureMapsKey === key
|
||
&& lmeState.baseLayers["azure-road"]
|
||
&& lmeState.baseLayers["azure-satellite"]) {
|
||
return;
|
||
}
|
||
|
||
const activeAzureLayer = lmeState.currentBaseName.startsWith("azure-")
|
||
? lmeState.currentBaseName
|
||
: null;
|
||
if (activeAzureLayer && lmeState.currentBase) {
|
||
lmeState.map.removeLayer(lmeState.currentBase);
|
||
}
|
||
|
||
lmeState.azureMapsKey = key;
|
||
lmeState.baseLayers["azure-road"] = createAzureLayer(key, "microsoft.base.road", 22);
|
||
lmeState.baseLayers["azure-satellite"] = createAzureLayer(key, "microsoft.imagery", 19);
|
||
|
||
if (activeAzureLayer) {
|
||
lmeState.currentBase = lmeState.baseLayers[activeAzureLayer].addTo(lmeState.map);
|
||
lmeState.currentBase.bringToBack();
|
||
void updateAzureAttribution();
|
||
}
|
||
}
|
||
|
||
function clearAzureMaps() {
|
||
if (lmeState.currentBaseName.startsWith("azure-") && lmeState.currentBase) {
|
||
lmeState.map.removeLayer(lmeState.currentBase);
|
||
lmeState.currentBase = lmeState.baseLayers.satellite.addTo(lmeState.map);
|
||
lmeState.currentBase.bringToBack();
|
||
lmeState.currentBaseName = "satellite";
|
||
}
|
||
|
||
delete lmeState.baseLayers["azure-road"];
|
||
delete lmeState.baseLayers["azure-satellite"];
|
||
lmeState.azureMapsKey = null;
|
||
clearAzureAttribution();
|
||
}
|
||
|
||
function createAzureLayer(key, tilesetId, maxNativeZoom) {
|
||
const url = `https://atlas.microsoft.com/map/tile?api-version=2024-04-01&tilesetId=${tilesetId}&zoom={z}&x={x}&y={y}&tileSize=256&language=it-IT&view=Auto&subscription-key=${encodeURIComponent(key)}`;
|
||
const layer = L.tileLayer(url, {
|
||
minZoom: tilesetId === "microsoft.imagery" ? 1 : 0,
|
||
maxNativeZoom,
|
||
maxZoom: 22,
|
||
attribution: "Microsoft Azure Maps"
|
||
});
|
||
let errorShown = false;
|
||
layer.on("tileerror", () => {
|
||
if (!errorShown) {
|
||
errorShown = true;
|
||
setInfo("Azure Maps non ha restituito le tessere. Controlla quota e permessi Render della chiave.");
|
||
}
|
||
});
|
||
return layer;
|
||
}
|
||
|
||
async function updateAzureAttribution() {
|
||
if (!lmeState.azureMapsKey || !lmeState.currentBaseName.startsWith("azure-")) {
|
||
return;
|
||
}
|
||
|
||
const requestedLayer = lmeState.currentBaseName;
|
||
const tilesetId = requestedLayer === "azure-satellite" ? "microsoft.imagery" : "microsoft.base.road";
|
||
const bounds = lmeState.map.getBounds();
|
||
const zoom = Math.min(lmeState.map.getZoom(), tilesetId === "microsoft.imagery" ? 19 : 22);
|
||
const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()].join(",");
|
||
const url = `https://atlas.microsoft.com/map/attribution?api-version=2024-04-01&tilesetId=${tilesetId}&zoom=${zoom}&bounds=${bbox}`;
|
||
|
||
try {
|
||
const response = await fetch(url, {
|
||
headers: {
|
||
Accept: "application/json",
|
||
"subscription-key": lmeState.azureMapsKey
|
||
}
|
||
});
|
||
if (!response.ok) {
|
||
return;
|
||
}
|
||
const payload = await response.json();
|
||
if (lmeState.currentBaseName !== requestedLayer) {
|
||
return;
|
||
}
|
||
|
||
const attribution = [...new Set((payload.copyrights || []).map(htmlToText).filter(Boolean))].join(" | ");
|
||
setAzureAttribution(attribution);
|
||
} catch {
|
||
clearAzureAttribution();
|
||
}
|
||
}
|
||
|
||
function setAzureAttribution(text) {
|
||
clearAzureAttribution();
|
||
if (!text) {
|
||
return;
|
||
}
|
||
lmeState.azureAttribution = escapeHtml(text);
|
||
}
|
||
|
||
function clearAzureAttribution() {
|
||
lmeState.azureAttribution = null;
|
||
}
|
||
|
||
function htmlToText(value) {
|
||
const element = document.createElement("div");
|
||
element.innerHTML = String(value || "");
|
||
return element.textContent?.trim() || "";
|
||
}
|
||
|
||
function setCadastreOpacity(value) {
|
||
const opacity = Number(value);
|
||
if (!Number.isFinite(opacity)) {
|
||
return;
|
||
}
|
||
lmeState.cadastreOpacity = Math.max(0, Math.min(1, opacity));
|
||
if (lmeState.cadastre) {
|
||
lmeState.cadastre.setOpacity(lmeState.cadastreOpacity);
|
||
}
|
||
if (lmeState.cadastreZoning) {
|
||
lmeState.cadastreZoning.setOpacity(Math.max(0.8, lmeState.cadastreOpacity));
|
||
}
|
||
}
|
||
|
||
function toggleCadastreLayer(layer, enabled) {
|
||
const exists = lmeState.cadastreLayers.includes(layer);
|
||
if (enabled && !exists) {
|
||
lmeState.cadastreLayers.push(layer);
|
||
}
|
||
if (!enabled && exists) {
|
||
lmeState.cadastreLayers = lmeState.cadastreLayers.filter(item => item !== layer);
|
||
}
|
||
refreshCadastreLayer();
|
||
if (layer === "CP.CadastralZoning") {
|
||
if (enabled) {
|
||
scheduleNavigationSheetRefresh(true);
|
||
} else {
|
||
clearNavigationSheetOutline();
|
||
}
|
||
}
|
||
const layerNames = {
|
||
"CP.CadastralParcel": "Confini particelle",
|
||
codice_plla: "Numeri particelle",
|
||
"CP.CadastralZoning": "Fogli catastali",
|
||
fabbricati: "Fabbricati",
|
||
strade: "Strade catastali",
|
||
acque: "Acque"
|
||
};
|
||
if (enabled && layer === "codice_plla" && lmeState.map.getZoom() < 17) {
|
||
setInfo("Numeri particelle attivati. Ingrandisci la mappa almeno fino a zoom 17 per renderli visibili.");
|
||
return;
|
||
}
|
||
setInfo(`${layerNames[layer] || layer}: ${enabled ? "attivo" : "nascosto"}.`);
|
||
}
|
||
|
||
function scheduleNavigationSheetRefresh(immediate = false) {
|
||
window.clearTimeout(lmeState.navigationSheetRefreshTimer);
|
||
if (!lmeState.map
|
||
|| !lmeState.dotNetReference
|
||
|| !lmeState.cadastreLayers.includes("CP.CadastralZoning")) {
|
||
return;
|
||
}
|
||
if (lmeState.map.getZoom() < 15) {
|
||
clearNavigationSheetOutline();
|
||
return;
|
||
}
|
||
lmeState.navigationSheetRefreshTimer = window.setTimeout(
|
||
() => void refreshNavigationSheetOutline(),
|
||
immediate ? 0 : 650);
|
||
}
|
||
|
||
async function refreshNavigationSheetOutline() {
|
||
if (!lmeState.map
|
||
|| !lmeState.dotNetReference
|
||
|| !lmeState.cadastreLayers.includes("CP.CadastralZoning")) {
|
||
return;
|
||
}
|
||
|
||
const center = lmeState.map.getCenter();
|
||
const point = [center.lng, center.lat];
|
||
if (loadedCadastralSheetAt(point)) {
|
||
clearNavigationSheetOutline();
|
||
return;
|
||
}
|
||
if (lmeState.navigationSheetFeature?.geometry
|
||
&& geometryContainsPoint(lmeState.navigationSheetFeature.geometry, point)) {
|
||
lmeState.navigationSheetLabel?.setLatLng(center);
|
||
return;
|
||
}
|
||
|
||
const requestId = ++lmeState.navigationSheetRequestId;
|
||
try {
|
||
const xml = await lmeState.dotNetReference.invokeMethodAsync(
|
||
"GetOfficialCadastralSheetsNear",
|
||
center.lat,
|
||
center.lng);
|
||
if (requestId !== lmeState.navigationSheetRequestId
|
||
|| !lmeState.cadastreLayers.includes("CP.CadastralZoning")) {
|
||
return;
|
||
}
|
||
|
||
const candidates = gmlParcelsToGeoJson(xml).features || [];
|
||
const sheet = findContainingPolygon(point, { type: "FeatureCollection", features: candidates })
|
||
|| candidates[0];
|
||
if (!sheet) {
|
||
return;
|
||
}
|
||
|
||
const reference = String(sheet.properties?.NATIONALCADASTRALZONINGREFERENCE
|
||
|| sheet.properties?.LABEL
|
||
|| "").trim();
|
||
const previousReference = String(
|
||
lmeState.navigationSheetFeature?.properties?.NATIONALCADASTRALZONINGREFERENCE
|
||
|| lmeState.navigationSheetFeature?.properties?.LABEL
|
||
|| "").trim();
|
||
lmeState.navigationSheetFeature = sheet;
|
||
lmeState.navigationSheetLayer.clearLayers();
|
||
L.geoJSON(sheet, {
|
||
pane: "navigationSheetPane",
|
||
interactive: false,
|
||
style: {
|
||
color: "#f59e0b",
|
||
weight: 4,
|
||
opacity: 0.95,
|
||
dashArray: "10 6",
|
||
fillColor: "#fbbf24",
|
||
fillOpacity: 0.055
|
||
}
|
||
}).addTo(lmeState.navigationSheetLayer);
|
||
lmeState.navigationSheetLabel = L.marker(center, {
|
||
pane: "navigationSheetPane",
|
||
interactive: false,
|
||
icon: L.divIcon({
|
||
className: "cadastral-sheet-label",
|
||
html: escapeHtml(reference ? `Foglio ${reference}` : "Foglio catastale")
|
||
})
|
||
}).addTo(lmeState.navigationSheetLayer);
|
||
if (reference && reference !== previousReference) {
|
||
setInfo(`Confine del foglio catastale ${reference} caricato.`);
|
||
}
|
||
} catch {
|
||
if (requestId === lmeState.navigationSheetRequestId) {
|
||
setInfo("Confine del foglio catastale non disponibile dal servizio AdE. Riprovo al prossimo spostamento.");
|
||
}
|
||
}
|
||
}
|
||
|
||
function clearNavigationSheetOutline() {
|
||
window.clearTimeout(lmeState.navigationSheetRefreshTimer);
|
||
lmeState.navigationSheetRequestId++;
|
||
lmeState.navigationSheetFeature = null;
|
||
lmeState.navigationSheetLabel = null;
|
||
lmeState.navigationSheetLayer?.clearLayers();
|
||
}
|
||
|
||
function loadedCadastralSheetAt(point) {
|
||
const features = [
|
||
...(lmeState.importedSheets?.toGeoJSON?.()?.features || []),
|
||
...(lmeState.imported?.toGeoJSON?.()?.features || [])
|
||
].filter(feature => ["Polygon", "MultiPolygon"].includes(feature?.geometry?.type)
|
||
&& (feature.properties?._lmeOfficialSheet
|
||
|| feature.properties?.NATIONALCADASTRALZONINGREFERENCE
|
||
|| isSheetOrOverlayFeature(feature)));
|
||
return findContainingPolygon(point, { type: "FeatureCollection", features });
|
||
}
|
||
|
||
function refreshCadastreLayer() {
|
||
if (lmeState.cadastre) {
|
||
lmeState.map.removeLayer(lmeState.cadastre);
|
||
}
|
||
if (lmeState.cadastreZoning) {
|
||
lmeState.map.removeLayer(lmeState.cadastreZoning);
|
||
}
|
||
|
||
const zoningEnabled = lmeState.cadastreLayers.includes("CP.CadastralZoning");
|
||
const layers = activeCadastreLayers().filter(layer => layer !== "CP.CadastralZoning");
|
||
lmeState.cadastre = null;
|
||
lmeState.cadastreZoning = null;
|
||
|
||
if (layers.length) {
|
||
const cadastreLayer = L.tileLayer.wms(cadastreUrl(), {
|
||
pane: "cadastrePane",
|
||
layers: layers.join(","),
|
||
format: "image/png",
|
||
transparent: true,
|
||
version: "1.1.1",
|
||
crs: cadastreCrs,
|
||
maxZoom: 22,
|
||
opacity: lmeState.cadastreOpacity,
|
||
attribution: "Agenzia delle Entrate - CC BY 4.0"
|
||
});
|
||
attachCadastreTileRecovery(cadastreLayer);
|
||
lmeState.cadastre = cadastreLayer.addTo(lmeState.map);
|
||
}
|
||
|
||
if (zoningEnabled) {
|
||
const zoningLayer = L.tileLayer.wms(cadastreUrl(), {
|
||
pane: "cadastreZoningPane",
|
||
layers: "CP.CadastralZoning",
|
||
format: "image/png",
|
||
transparent: true,
|
||
version: "1.1.1",
|
||
crs: cadastreCrs,
|
||
minZoom: 8,
|
||
maxZoom: 14,
|
||
opacity: Math.max(0.8, lmeState.cadastreOpacity),
|
||
attribution: "Agenzia delle Entrate - CC BY 4.0"
|
||
});
|
||
attachCadastreTileRecovery(zoningLayer);
|
||
lmeState.cadastreZoning = zoningLayer.addTo(lmeState.map);
|
||
scheduleNavigationSheetRefresh(true);
|
||
}
|
||
}
|
||
|
||
function attachCadastreTileRecovery(cadastreLayer) {
|
||
let errorShown = false;
|
||
cadastreLayer.on("tileerror", event => {
|
||
const tile = event?.tile;
|
||
const retryCount = Number(tile?.dataset?.lmeRetry || 0);
|
||
if (tile && retryCount < 2) {
|
||
tile.dataset.lmeRetry = String(retryCount + 1);
|
||
window.setTimeout(() => {
|
||
const separator = tile.src.includes("?") ? "&" : "?";
|
||
tile.src = `${tile.src}${separator}_lme_retry=${Date.now()}`;
|
||
}, 450 * (retryCount + 1));
|
||
return;
|
||
}
|
||
|
||
if (errorShown) {
|
||
return;
|
||
}
|
||
|
||
errorShown = true;
|
||
setInfo("Il servizio cartografico AdE non ha risposto. La connessione internet puo essere attiva: attendi qualche secondo e riprova.");
|
||
});
|
||
cadastreLayer.on("load", () => {
|
||
errorShown = false;
|
||
});
|
||
}
|
||
|
||
function activeCadastreLayers() {
|
||
const drawOrder = [
|
||
"CP.CadastralZoning",
|
||
"strade",
|
||
"acque",
|
||
"fabbricati",
|
||
"CP.CadastralParcel",
|
||
"codice_plla"
|
||
];
|
||
return drawOrder.filter(layer => lmeState.cadastreLayers.includes(layer));
|
||
}
|
||
|
||
function setFiducialPoints(points, color, visible = true, keepPrevious = false, municipalityCode = "", sheet = "", municipalityName = "") {
|
||
if (!lmeState.fiducials) return;
|
||
const source = Array.isArray(points) ? points : [];
|
||
if (source.length || lmeState.fiducials.getLayers().length) rememberMapChange();
|
||
const normalizedMunicipality = String(municipalityCode || "").trim().toUpperCase();
|
||
const normalizedSheet = normalizeFiducialSheet(sheet);
|
||
const sheetKey = normalizedMunicipality && normalizedSheet
|
||
? `${normalizedMunicipality}:${normalizedSheet}`
|
||
: "fiducials-current";
|
||
const sheetLabel = `Foglio ${normalizedSheet || sheet}${municipalityName ? ` · ${municipalityName}` : ""}`;
|
||
if (!keepPrevious) {
|
||
managedRecordsForCategory(layerManagerCategories().find(category => category.key === "fiducials"))
|
||
.forEach(record => lmeState.managerSelectedObjectIds.delete(record.id));
|
||
lmeState.fiducials.clearLayers();
|
||
clearManagedCategoryState("fiducials");
|
||
} else {
|
||
managedRecordsForCategory(layerManagerCategories().find(category => category.key === "fiducials"))
|
||
.filter(record => record.layer?.feature?.properties?._lmeFiducialSheetKey === sheetKey)
|
||
.forEach(record => {
|
||
record.parent?.removeLayer?.(record.layer);
|
||
clearManagedObjectState(record.id);
|
||
lmeState.managerSelectedObjectIds.delete(record.id);
|
||
});
|
||
}
|
||
if (typeof color === "string" && /^#[0-9a-f]{6}$/i.test(color)) {
|
||
lmeState.fiducialColor = color;
|
||
}
|
||
source.forEach((point, index) => {
|
||
const latitude = Number(point.latitude ?? point.Latitude);
|
||
const longitude = Number(point.longitude ?? point.Longitude);
|
||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return;
|
||
const feature = {
|
||
type: "Feature",
|
||
geometry: { type: "Point", coordinates: [longitude, latitude] },
|
||
properties: {
|
||
name: String(point.identifier ?? point.Identifier ?? `PF ${index + 1}`),
|
||
_lmeDisplayName: String(point.identifier ?? point.Identifier ?? `PF ${index + 1}`),
|
||
_lmeSource: sheetKey,
|
||
_lmeGroupName: sheetLabel,
|
||
_lmeFiducialSheetKey: sheetKey,
|
||
_lmeMunicipalityCode: normalizedMunicipality,
|
||
_lmeSheet: normalizedSheet,
|
||
_lmeColor: lmeState.fiducialColor,
|
||
_lmeFillColor: lmeState.fiducialColor,
|
||
_lmeWeight: 3,
|
||
_lmeOpacity: 1,
|
||
_lmeFillOpacity: 0.72,
|
||
cadastralNorth: point.cadastralNorth ?? point.CadastralNorth ?? null,
|
||
cadastralEast: point.cadastralEast ?? point.CadastralEast ?? null,
|
||
reliability: String(point.reliability ?? point.Reliability ?? ""),
|
||
elevation: point.elevation ?? point.Elevation ?? null,
|
||
parcel: String(point.parcel ?? point.Parcel ?? ""),
|
||
updatedAt: String(point.updatedAt ?? point.UpdatedAt ?? ""),
|
||
originId: String(point.originId ?? point.OriginId ?? ""),
|
||
monographUrl: String(point.monographUrl ?? point.MonographUrl ?? "")
|
||
}
|
||
};
|
||
const marker = createFiducialMarker(feature, L.latLng(latitude, longitude));
|
||
marker.feature = feature;
|
||
bindFiducialPopup(feature, marker);
|
||
lmeState.fiducials.addLayer(marker);
|
||
});
|
||
setFiducialPointsVisible(visible);
|
||
refreshLayerManager();
|
||
if (source.length) finishMapChange();
|
||
}
|
||
|
||
function createFiducialMarker(feature, latlng) {
|
||
const properties = feature?.properties || {};
|
||
const color = properties._lmeColor || lmeState.fiducialColor;
|
||
return L.circleMarker(latlng, {
|
||
pane: "fiducialsPane",
|
||
radius: 8,
|
||
color,
|
||
fillColor: properties._lmeFillColor || color,
|
||
weight: Number(properties._lmeWeight) || 3,
|
||
opacity: Number.isFinite(Number(properties._lmeOpacity)) ? Number(properties._lmeOpacity) : 1,
|
||
fillOpacity: Number.isFinite(Number(properties._lmeFillOpacity)) ? Number(properties._lmeFillOpacity) : 0.72
|
||
});
|
||
}
|
||
|
||
function bindFiducialPopup(feature, layer) {
|
||
const properties = feature?.properties || {};
|
||
const rows = [];
|
||
if (properties.cadastralNorth != null) rows.push(`Nord/X catastale: ${escapeHtml(properties.cadastralNorth)}`);
|
||
if (properties.cadastralEast != null) rows.push(`Est/Y catastale: ${escapeHtml(properties.cadastralEast)}`);
|
||
if (properties.reliability) rows.push(`Attendibilita: ${escapeHtml(properties.reliability)}`);
|
||
if (properties.elevation != null) rows.push(`Quota: ${escapeHtml(properties.elevation)}`);
|
||
if (properties.parcel) rows.push(`Particella: ${escapeHtml(properties.parcel)}`);
|
||
const url = String(properties.monographUrl || "");
|
||
const monograph = /^https:\/\//i.test(url)
|
||
? `<p><a href="${escapeHtml(url)}" target="_blank" rel="noopener">Apri monografia ufficiale</a></p>`
|
||
: "";
|
||
layer.bindPopup(`<strong>${escapeHtml(properties._lmeDisplayName || properties.name || "Punto fiduciale")}</strong><p>${rows.join("<br>")}</p>${monograph}<small>Posizione cartografica sperimentale GloboGIS: verificare prima dell'uso topografico.</small>`);
|
||
applyStyleFromProperties(layer);
|
||
}
|
||
|
||
function setFiducialPointsVisible(enabled) {
|
||
lmeState.fiducialsVisible = Boolean(enabled);
|
||
if (!lmeState.map || !lmeState.fiducials) return;
|
||
if (lmeState.fiducialsVisible) {
|
||
lmeState.managerHiddenCategories.delete("fiducials");
|
||
if (!lmeState.map.hasLayer(lmeState.fiducials)) lmeState.fiducials.addTo(lmeState.map);
|
||
lmeState.fiducials.bringToFront?.();
|
||
} else {
|
||
lmeState.managerHiddenCategories.add("fiducials");
|
||
if (lmeState.map.hasLayer(lmeState.fiducials)) lmeState.map.removeLayer(lmeState.fiducials);
|
||
}
|
||
refreshLayerManager();
|
||
}
|
||
|
||
function setFiducialColor(color) {
|
||
if (!/^#[0-9a-f]{6}$/i.test(String(color))) return;
|
||
lmeState.fiducialColor = color;
|
||
lmeState.fiducials?.eachLayer(layer => {
|
||
if (layer.feature?.properties) {
|
||
layer.feature.properties._lmeColor = color;
|
||
layer.feature.properties._lmeFillColor = color;
|
||
}
|
||
applyStyleFromProperties(layer);
|
||
});
|
||
refreshLayerManager();
|
||
}
|
||
|
||
function fitFiducialPoints() {
|
||
if (!lmeState.fiducials?.getLayers().length) {
|
||
setInfo("Nessun Punto Fiduciale da inquadrare.");
|
||
return;
|
||
}
|
||
fitLayer(lmeState.fiducials);
|
||
}
|
||
|
||
function clearSelectedParcels() {
|
||
const count = managedGroupGeoJson(lmeState.selectedParcelsLayer, "selectedParcels").features.length;
|
||
if (count) rememberMapChange();
|
||
lmeState.selectedParcelsLayer.clearLayers();
|
||
clearManagedCategoryState("selectedParcels");
|
||
updateGpsInsideState();
|
||
setInfo(count ? `${count} particelle selezionate rimosse.` : "Non ci sono particelle selezionate da rimuovere.");
|
||
refreshLayerManager();
|
||
if (count) finishMapChange();
|
||
}
|
||
|
||
function setSelectedParcelsVisible(enabled) {
|
||
lmeState.selectedParcelsVisible = Boolean(enabled);
|
||
if (!lmeState.map || !lmeState.selectedParcelsLayer) {
|
||
return;
|
||
}
|
||
|
||
if (lmeState.selectedParcelsVisible) {
|
||
if (!lmeState.map.hasLayer(lmeState.selectedParcelsLayer)) {
|
||
lmeState.selectedParcelsLayer.addTo(lmeState.map);
|
||
}
|
||
lmeState.selectedParcelsLayer.bringToFront();
|
||
const count = lmeState.selectedParcelsLayer.toGeoJSON().features?.length || 0;
|
||
setInfo(count
|
||
? `${count} particelle selezionate visibili.`
|
||
: "Nessuna particella selezionata da mostrare.");
|
||
return;
|
||
}
|
||
|
||
if (lmeState.map.hasLayer(lmeState.selectedParcelsLayer)) {
|
||
lmeState.map.removeLayer(lmeState.selectedParcelsLayer);
|
||
}
|
||
setInfo("Particelle selezionate nascoste; i dati restano nel progetto.");
|
||
}
|
||
|
||
function clearMapElements() {
|
||
const count = managedGroupGeoJson(lmeState.selectedParcelsLayer, "selectedParcels").features.length
|
||
+ managedGroupGeoJson(lmeState.gpsParcelLayer, "gpsParcels").features.length
|
||
+ managedGroupGeoJson(lmeState.fiducials, "fiducials").features.length
|
||
+ managedGroupGeoJson(lmeState.imported, "imported").features.length
|
||
+ managedGroupGeoJson(lmeState.importedSheets, "importedSheets").features.length
|
||
+ managedGroupGeoJson(lmeState.drawings, "drawings").features.length
|
||
+ lmeState.gpsTrailPoints.length;
|
||
if (!count) {
|
||
setInfo("La mappa non contiene elementi da pulire.");
|
||
return;
|
||
}
|
||
if (!window.confirm("Rimuovere particelle evidenziate, Punti Fiduciali, file importati, disegni, POI e traccia GPS dalla mappa?")) {
|
||
return;
|
||
}
|
||
|
||
rememberMapChange();
|
||
resetLayerManagerProjectState();
|
||
lmeState.selectedParcelsLayer.clearLayers();
|
||
lmeState.gpsParcelLayer.clearLayers();
|
||
lmeState.fiducials.clearLayers();
|
||
lmeState.imported.clearLayers();
|
||
lmeState.importedSheets.clearLayers();
|
||
lmeState.drawings.clearLayers();
|
||
lmeState.importedMeasurements.clearLayers();
|
||
lmeState.drawingMeasurements.clearLayers();
|
||
lmeState.gpsTrailPoints = [];
|
||
lmeState.gpsTrail.setLatLngs([]);
|
||
updateGpsInsideState();
|
||
setInfo("Elementi della mappa rimossi. Sfondo e layer catastali restano attivi.");
|
||
refreshLayerManager();
|
||
finishMapChange();
|
||
}
|
||
|
||
function startGps() {
|
||
if (!navigator.geolocation) {
|
||
setInfo("Geolocalizzazione non disponibile sul dispositivo.");
|
||
setText("gpsState", "GPS non disponibile");
|
||
return;
|
||
}
|
||
|
||
if (lmeState.gpsWatchId !== null) {
|
||
setInfo("Tracking GPS gia attivo.");
|
||
return;
|
||
}
|
||
|
||
lmeState.gpsWatchId = navigator.geolocation.watchPosition(onGpsPosition, onGpsError, {
|
||
enableHighAccuracy: true,
|
||
maximumAge: 1000,
|
||
timeout: 15000
|
||
});
|
||
setText("gpsState", "Aggancio GPS in corso...");
|
||
setInfo("Tracking GPS avviato.");
|
||
}
|
||
|
||
function stopGps() {
|
||
if (lmeState.gpsWatchId !== null) {
|
||
navigator.geolocation.clearWatch(lmeState.gpsWatchId);
|
||
lmeState.gpsWatchId = null;
|
||
}
|
||
setText("gpsState", "GPS fermo");
|
||
setInfo("Tracking GPS fermato.");
|
||
}
|
||
|
||
function setNativeGpsPosition(location) {
|
||
onGpsPosition({
|
||
coords: {
|
||
latitude: location.latitude,
|
||
longitude: location.longitude,
|
||
accuracy: location.accuracy || 0,
|
||
altitude: location.altitude,
|
||
heading: location.course,
|
||
speed: location.speed
|
||
},
|
||
timestamp: location.timestamp
|
||
});
|
||
}
|
||
|
||
function setGpsPositionVisible(enabled) {
|
||
lmeState.gpsPositionVisible = Boolean(enabled);
|
||
if (!lmeState.gpsPositionVisible) {
|
||
if (lmeState.gpsMarker && lmeState.map.hasLayer(lmeState.gpsMarker)) {
|
||
lmeState.map.removeLayer(lmeState.gpsMarker);
|
||
}
|
||
if (lmeState.gpsAccuracy && lmeState.map.hasLayer(lmeState.gpsAccuracy)) {
|
||
lmeState.map.removeLayer(lmeState.gpsAccuracy);
|
||
}
|
||
setInfo("Punto e cerchio GPS nascosti. Il rilevamento continua.");
|
||
return;
|
||
}
|
||
|
||
if (lmeState.hasLiveGpsFix && lmeState.lastPosition) {
|
||
updateGpsPositionLayers(lmeState.lastPosition, lmeState.lastAccuracy);
|
||
}
|
||
setInfo("Punto e cerchio GPS visibili.");
|
||
}
|
||
|
||
function setGpsError(error) {
|
||
setText("gpsState", "GPS non disponibile");
|
||
setInfo(error || "Errore GPS.");
|
||
}
|
||
|
||
function centerOnGps() {
|
||
if (!lmeState.lastPosition) {
|
||
setInfo("Nessuna posizione GPS ancora disponibile.");
|
||
return;
|
||
}
|
||
lmeState.map.setView(lmeState.lastPosition, Math.max(lmeState.map.getZoom(), 19));
|
||
}
|
||
|
||
function toggleTrail(enabled) {
|
||
lmeState.gpsTrailEnabled = enabled;
|
||
if (enabled && !lmeState.gpsTrailStartedAt) lmeState.gpsTrailStartedAt = new Date().toISOString();
|
||
setInfo(enabled ? "Registrazione percorso avviata." : "Registrazione percorso in pausa.");
|
||
}
|
||
|
||
function clearGpsTrail() {
|
||
if (!lmeState.gpsTrailPoints.length) { setInfo("La traccia GPS e' vuota."); return false; }
|
||
if (!window.confirm("Eliminare definitivamente la traccia GPS corrente?")) return false;
|
||
rememberMapChange();
|
||
lmeState.gpsTrailPoints = [];
|
||
lmeState.gpsTrail.setLatLngs([]);
|
||
lmeState.gpsTrailStartedAt = null;
|
||
finishMapChange("Eliminazione traccia GPS");
|
||
setInfo("Traccia GPS eliminata.");
|
||
return true;
|
||
}
|
||
|
||
function getGpsTrailStats() {
|
||
const length = lmeState.gpsTrailPoints.reduce((sum, point, index) => index ? sum + lmeState.map.distance(lmeState.gpsTrailPoints[index - 1], point) : 0, 0);
|
||
const durationSeconds = lmeState.gpsTrailStartedAt ? Math.max(0, Math.round((Date.now() - new Date(lmeState.gpsTrailStartedAt).getTime()) / 1000)) : 0;
|
||
return { points: lmeState.gpsTrailPoints.length, length, durationSeconds, recording: lmeState.gpsTrailEnabled };
|
||
}
|
||
|
||
function onGpsPosition(position) {
|
||
const { latitude, longitude, accuracy } = position.coords;
|
||
const latlng = [latitude, longitude];
|
||
lmeState.lastPosition = latlng;
|
||
lmeState.lastAccuracy = Math.max(0, Number(accuracy) || 0);
|
||
const firstLiveFix = !lmeState.hasLiveGpsFix;
|
||
lmeState.hasLiveGpsFix = true;
|
||
updateGpsPositionLayers(latlng, lmeState.lastAccuracy);
|
||
if (firstLiveFix) {
|
||
lmeState.map.setView(latlng, 19);
|
||
} else if (!lmeState.map.getBounds().contains(latlng)) {
|
||
lmeState.map.panTo(latlng, {
|
||
animate: true,
|
||
duration: 0.5,
|
||
easeLinearity: 0.25
|
||
});
|
||
}
|
||
|
||
if (lmeState.gpsTrailEnabled) {
|
||
lmeState.gpsTrailPoints.push(latlng);
|
||
lmeState.gpsTrail.setLatLngs(lmeState.gpsTrailPoints);
|
||
if (lmeState.gpsTrailPoints.length % 10 === 0) finishMapChange();
|
||
}
|
||
|
||
setText("gpsState", `${latitude.toFixed(6)}, ${longitude.toFixed(6)} - precisione ${Math.round(lmeState.lastAccuracy)} m`);
|
||
updateGpsInsideState();
|
||
}
|
||
|
||
function updateGpsPositionLayers(latlng, accuracy) {
|
||
let created = false;
|
||
if (!lmeState.gpsMarker) {
|
||
lmeState.gpsMarker = L.marker(latlng, { pane: "gpsPositionPane", title: "La tua posizione" });
|
||
lmeState.gpsMarker.lmeManagerId = "gps-position-marker";
|
||
lmeState.gpsMarker.lmeManagerLabel = "Posizione GPS";
|
||
lmeState.gpsAccuracy = L.circle(latlng, {
|
||
pane: "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; }
|
||
if (!canAddDrawingObjects()) return;
|
||
rememberMapChange();
|
||
const now = new Date();
|
||
addDrawingFeature({ type: "Feature", properties: {
|
||
name: `Rilievo GPS ${now.toLocaleString("it-IT")}`,
|
||
_lmeAccuracy: lmeState.lastAccuracy,
|
||
_lmeMeasuredAt: now.toISOString(),
|
||
_lmeNotes: `Precisione dichiarata: ${Math.round(lmeState.lastAccuracy)} m`
|
||
}, geometry: { type: "Point", coordinates: [lmeState.lastPosition[1], lmeState.lastPosition[0]] } }, true);
|
||
refreshLayerManager(); finishMapChange(); setInfo("Punto GPS salvato con precisione, data e ora.");
|
||
}
|
||
|
||
function gmlParcelsToGeoJson(xmlText) {
|
||
const xml = new DOMParser().parseFromString(xmlText, "text/xml");
|
||
const members = Array.from(xml.getElementsByTagNameNS("*", "member"));
|
||
const features = members.map(member => parcelMemberToFeature(member)).filter(Boolean);
|
||
return { type: "FeatureCollection", features };
|
||
}
|
||
|
||
function parcelMemberToFeature(member) {
|
||
const posList = member.getElementsByTagNameNS("*", "posList")[0]?.textContent?.trim();
|
||
if (!posList) {
|
||
return null;
|
||
}
|
||
|
||
const values = posList.split(/\s+/).map(Number).filter(value => !Number.isNaN(value));
|
||
const ring = [];
|
||
for (let index = 0; index < values.length; index += 2) {
|
||
ring.push([values[index + 1], values[index]]);
|
||
}
|
||
|
||
const properties = {};
|
||
[
|
||
"INSPIREID_LOCALID",
|
||
"INSPIREID_NAMESPACE",
|
||
"LABEL",
|
||
"NATIONALCADASTRALREFERENCE",
|
||
"NATIONALCADASTRALZONINGREFERENCE",
|
||
"LEVEL",
|
||
"LEVELNAME",
|
||
"ADMINISTRATIVEUNIT"
|
||
].forEach(name => {
|
||
const value = member.getElementsByTagNameNS("*", name)[0]?.textContent;
|
||
if (value) {
|
||
properties[name] = value;
|
||
}
|
||
});
|
||
|
||
return {
|
||
type: "Feature",
|
||
properties,
|
||
geometry: {
|
||
type: "Polygon",
|
||
coordinates: [ring]
|
||
}
|
||
};
|
||
}
|
||
|
||
function findContainingPolygon(point, geojson) {
|
||
const features = geojson?.features || [];
|
||
return features.find(feature => feature.geometry && geometryContainsPoint(feature.geometry, point));
|
||
}
|
||
|
||
function geometryContainsPoint(geometry, point) {
|
||
if (geometry.type === "Polygon") {
|
||
return polygonContainsPoint(geometry.coordinates, point);
|
||
}
|
||
if (geometry.type === "MultiPolygon") {
|
||
return geometry.coordinates.some(polygon => polygonContainsPoint(polygon, point));
|
||
}
|
||
if (geometry.type === "GeometryCollection") {
|
||
return geometry.geometries.some(item => geometryContainsPoint(item, point));
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function polygonContainsPoint(rings, point) {
|
||
if (!rings.length || !ringContainsPoint(rings[0], point)) {
|
||
return false;
|
||
}
|
||
return !rings.slice(1).some(ring => ringContainsPoint(ring, point));
|
||
}
|
||
|
||
function ringContainsPoint(ring, point) {
|
||
const [x, y] = point;
|
||
let inside = false;
|
||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||
const xi = ring[i][0], yi = ring[i][1];
|
||
const xj = ring[j][0], yj = ring[j][1];
|
||
const intersect = ((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi);
|
||
if (intersect) {
|
||
inside = !inside;
|
||
}
|
||
}
|
||
return inside;
|
||
}
|
||
|
||
async function queryFeatureInfo(latlng) {
|
||
if (!lmeState.cadastreLayers.includes("CP.CadastralParcel")) {
|
||
return "";
|
||
}
|
||
|
||
const point = lmeState.map.latLngToContainerPoint(latlng, lmeState.map.getZoom());
|
||
const size = lmeState.map.getSize();
|
||
const bbox = lmeState.map.getBounds();
|
||
const wmsBbox = `${bbox.getSouth()},${bbox.getWest()},${bbox.getNorth()},${bbox.getEast()}`;
|
||
const url = `${cadastreUrl()}service=WMS&version=1.3.0&request=GetFeatureInfo&layers=CP.CadastralParcel&query_layers=CP.CadastralParcel&styles=&crs=EPSG:4258&bbox=${wmsBbox}&width=${size.x}&height=${size.y}&i=${Math.round(point.x)}&j=${Math.round(point.y)}&info_format=text/html`;
|
||
|
||
try {
|
||
const response = await fetch(url);
|
||
const text = await response.text();
|
||
return text.includes("<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) {
|
||
const container = document.getElementById("mapInfo");
|
||
if (!container) return;
|
||
|
||
setText("mapInfoText", text);
|
||
container.hidden = false;
|
||
if (lmeState.infoTimer) {
|
||
window.clearTimeout(lmeState.infoTimer);
|
||
lmeState.infoTimer = null;
|
||
}
|
||
if (lmeState.infoTimeoutSeconds > 0) {
|
||
lmeState.infoTimer = window.setTimeout(hideInfo, lmeState.infoTimeoutSeconds * 1000);
|
||
}
|
||
}
|
||
|
||
function hideInfo() {
|
||
const container = document.getElementById("mapInfo");
|
||
if (container) container.hidden = true;
|
||
if (lmeState.infoTimer) {
|
||
window.clearTimeout(lmeState.infoTimer);
|
||
lmeState.infoTimer = null;
|
||
}
|
||
}
|
||
|
||
function setInfoTimeout(seconds) {
|
||
const value = Number(seconds);
|
||
lmeState.infoTimeoutSeconds = Number.isFinite(value) ? Math.min(30, Math.max(0, value)) : 3;
|
||
const container = document.getElementById("mapInfo");
|
||
if (container && !container.hidden) {
|
||
setInfo(document.getElementById("mapInfoText")?.textContent || "");
|
||
}
|
||
}
|
||
|
||
function setText(id, text) {
|
||
const element = document.getElementById(id);
|
||
if (element) {
|
||
element.textContent = text;
|
||
}
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|