Rilascia gestione completa progetti e aggiornamenti 0.4.0
This commit is contained in:
1 parent
620a4bec26
commit
205a520708
10 files changed
+1366
-65
No files matched your search
+577
-30
@@ -46,6 +46,7 @@ const lmeState = {
|
||||
gpsParcelLayer: null,
|
||||
gpsTrailEnabled: false,
|
||||
gpsTrailPoints: [],
|
||||
gpsTrailStartedAt: null,
|
||||
gpsWatchId: null,
|
||||
lastPosition: null,
|
||||
fileImportBound: false,
|
||||
@@ -82,6 +83,13 @@ const lmeState = {
|
||||
gpsProximityEnabled: true,
|
||||
gpsBoundaryAlertEnabled: false,
|
||||
lastBoundaryState: null,
|
||||
trash: [],
|
||||
auditLog: [],
|
||||
isDirty: false,
|
||||
readOnly: false,
|
||||
changeNotifyTimer: null,
|
||||
offlineDbPromise: null,
|
||||
offlineTileCount: 0,
|
||||
dotNetReference: null
|
||||
};
|
||||
|
||||
@@ -115,6 +123,11 @@ window.lmeMap = {
|
||||
clearDrawings,
|
||||
undoMapChange,
|
||||
redoMapChange,
|
||||
getHistoryState,
|
||||
markProjectSaved,
|
||||
hasUnsavedChanges: () => lmeState.isDirty,
|
||||
confirmDiscardChanges,
|
||||
setProjectReadOnly,
|
||||
restoreAutosave,
|
||||
compareProjectSnapshots,
|
||||
setAdvancedOptions,
|
||||
@@ -133,10 +146,23 @@ window.lmeMap = {
|
||||
stopGps,
|
||||
centerOnGps,
|
||||
toggleTrail,
|
||||
clearGpsTrail,
|
||||
getGpsTrailStats,
|
||||
setNativeGpsPosition,
|
||||
setGpsPositionVisible,
|
||||
setGpsError,
|
||||
saveGpsMeasurementPoint,
|
||||
restoreLastTrashItem,
|
||||
emptyTrash,
|
||||
showObjectTable,
|
||||
showAuditLog,
|
||||
showCoordinateInspector,
|
||||
validateAndRepairSelected,
|
||||
createHoleFromSelected,
|
||||
compareSelectedGeometries,
|
||||
downloadOfflineArea,
|
||||
clearOfflineTiles,
|
||||
getOfflineTileStats,
|
||||
resize,
|
||||
showInfo: setInfo
|
||||
};
|
||||
@@ -150,7 +176,8 @@ function init(dotNetReference) {
|
||||
}
|
||||
|
||||
lmeState.map = L.map("map", {
|
||||
zoomControl: false
|
||||
zoomControl: false,
|
||||
attributionControl: false
|
||||
}).setView([41.9028, 12.4964], 17);
|
||||
|
||||
L.control.zoom({ position: "bottomright" }).addTo(lmeState.map);
|
||||
@@ -191,12 +218,7 @@ function init(dotNetReference) {
|
||||
maxZoom: 22,
|
||||
attribution: "© OpenStreetMap contributors © CARTO"
|
||||
});
|
||||
lmeState.baseLayers.offline = L.gridLayer({ attribution: "Sfondo offline" });
|
||||
lmeState.baseLayers.offline.createTile = () => {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "lme-offline-tile";
|
||||
return tile;
|
||||
};
|
||||
lmeState.baseLayers.offline = createOfflineTileLayer();
|
||||
|
||||
lmeState.currentBase = lmeState.baseLayers.satellite.addTo(lmeState.map);
|
||||
restoreLayerOrder();
|
||||
@@ -262,6 +284,18 @@ function init(dotNetReference) {
|
||||
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 => {
|
||||
@@ -582,6 +616,9 @@ function bindFileImport() {
|
||||
}
|
||||
|
||||
try {
|
||||
if (file.size > 100 * 1024 * 1024) throw new Error("file superiore a 100 MB; suddividilo prima dell'importazione");
|
||||
setInfo(`Analisi di ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)...`);
|
||||
await new Promise(resolve => window.setTimeout(resolve, 20));
|
||||
const result = await readGisFile(file);
|
||||
rememberMapChange();
|
||||
const geojson = result.geojson;
|
||||
@@ -600,7 +637,8 @@ function bindFileImport() {
|
||||
? `; ${sheetCount} elementi foglio ${lmeState.importedSheetsVisible ? "visibili" : "nascosti"}`
|
||||
: "";
|
||||
const reprojected = result.reprojected ? "; coordinate Web Mercator convertite" : "";
|
||||
setInfo(`Caricato ${file.name}: ${featureCount} particelle/elementi${includedSheets}${ignored}${reprojected}.`);
|
||||
const warnings = result.validationWarnings ? `; ${result.validationWarnings} geometrie da verificare` : "";
|
||||
setInfo(`Caricato ${file.name}: ${featureCount} particelle/elementi${includedSheets}${ignored}${reprojected}${warnings}.`);
|
||||
updateGpsInsideState();
|
||||
refreshLayerManager();
|
||||
finishMapChange();
|
||||
@@ -717,6 +755,25 @@ function normalizeGeoJson(value, fileName) {
|
||||
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])
|
||||
@@ -731,7 +788,8 @@ function normalizeGeoJson(value, fileName) {
|
||||
geojson: root,
|
||||
sheetGeojson: emptyFeatureCollection(),
|
||||
ignoredCount: 0,
|
||||
reprojected: webMercator
|
||||
reprojected: webMercator,
|
||||
validationWarnings
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1216,6 +1274,7 @@ function managedObjectCanRename(record) {
|
||||
}
|
||||
|
||||
function renameManagedObject(record, index) {
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||||
if (!managedObjectCanRename(record)) {
|
||||
return;
|
||||
}
|
||||
@@ -1359,6 +1418,7 @@ function clearManagedObjectState(id) {
|
||||
}
|
||||
|
||||
function removeManagedRecords(records, subject) {
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||||
if (!records.length) {
|
||||
return;
|
||||
}
|
||||
@@ -1373,6 +1433,16 @@ function removeManagedRecords(records, subject) {
|
||||
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 => {
|
||||
@@ -1395,7 +1465,7 @@ function removeManagedRecords(records, subject) {
|
||||
refreshDrawingMeasurements();
|
||||
updateGpsInsideState();
|
||||
refreshLayerManager();
|
||||
finishMapChange();
|
||||
finishMapChange(`Rimozione: ${subject}`);
|
||||
setInfo(`${records.length === 1 ? "Oggetto rimosso" : `${records.length} oggetti rimossi`} dal progetto.`);
|
||||
}
|
||||
|
||||
@@ -1505,11 +1575,16 @@ function renderLayerManagerCategory(category, records) {
|
||||
const categoryVisible = !lmeState.managerHiddenCategories.has(category.key)
|
||||
&& (!category.roots.length || category.roots.some(layer => lmeState.map.hasLayer(layer)));
|
||||
const visible = categoryVisible;
|
||||
group.innerHTML = `<summary><button type="button" class="layer-drag-handle" aria-label="Trascina gruppo">☰</button><input type="checkbox" ${visible ? "checked" : ""} aria-label="Mostra ${escapeHtml(categoryName)}"><span class="layer-manager-title">${escapeHtml(categoryName)}</span>${rowActions}${layerManagerMoveButtons("gruppo")}<span class="layer-manager-count">${records.length}</span></summary><div class="layer-manager-children"></div>`;
|
||||
const categoryLocked = records.length > 0 && records.every(record => Boolean(record.layer?.feature?.properties?._lmeLocked));
|
||||
group.innerHTML = `<summary><button type="button" class="layer-drag-handle" aria-label="Trascina gruppo">☰</button><input type="checkbox" ${visible ? "checked" : ""} aria-label="Mostra ${escapeHtml(categoryName)}"><span class="layer-manager-title">${escapeHtml(categoryName)}</span><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>`;
|
||||
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();
|
||||
@@ -1553,7 +1628,8 @@ function renderLayerManagerBlock(category, source, records) {
|
||||
`<button type="button" class="layer-rename-button" aria-label="Rinomina gruppo" title="Rinomina">Rinomina</button>`,
|
||||
`<button type="button" class="layer-remove-button" aria-label="Rimuovi gruppo dal progetto" title="Rimuovi dal progetto">Rimuovi</button>`);
|
||||
block.classList.add("layer-manager-with-actions");
|
||||
block.innerHTML = `<summary><button type="button" class="layer-drag-handle" aria-label="Trascina blocco">☰</button><input type="checkbox" ${visible ? "checked" : ""} aria-label="Mostra ${escapeHtml(blockName)}"><span class="layer-manager-title">${escapeHtml(blockName)}</span>${rowActions}${layerManagerMoveButtons("blocco")}<span class="layer-manager-count">${records.length}</span></summary><div class="layer-manager-children"></div>`;
|
||||
const blockLocked = records.length > 0 && records.every(record => Boolean(record.layer?.feature?.properties?._lmeLocked));
|
||||
block.innerHTML = `<summary><button type="button" class="layer-drag-handle" aria-label="Trascina blocco">☰</button><input type="checkbox" ${visible ? "checked" : ""} aria-label="Mostra ${escapeHtml(blockName)}"><span class="layer-manager-title">${escapeHtml(blockName)}</span><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>`;
|
||||
const checkbox = block.querySelector("input");
|
||||
checkbox.indeterminate = visibleCount > 0 && !visible;
|
||||
checkbox.addEventListener("click", event => event.stopPropagation());
|
||||
@@ -1561,6 +1637,10 @@ function renderLayerManagerBlock(category, source, records) {
|
||||
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();
|
||||
@@ -1598,7 +1678,8 @@ function renderLayerManagerObject(record, index) {
|
||||
const rowActions = layerManagerRowActions(renameButton, removeButton);
|
||||
object.classList.toggle("layer-manager-with-actions", Boolean(rowActions));
|
||||
const objectName = managedObjectLabel(record, index);
|
||||
object.innerHTML = `<summary><button type="button" class="layer-drag-handle" aria-label="Trascina oggetto">☰</button><input type="checkbox" ${visible ? "checked" : ""} aria-label="Mostra oggetto"><span class="layer-manager-title">${escapeHtml(objectName)}</span>${rowActions}${layerManagerMoveButtons("oggetto")}<span class="layer-manager-count">›</span></summary><div class="layer-manager-details"></div>`;
|
||||
const locked = Boolean(record.layer?.feature?.properties?._lmeLocked);
|
||||
object.innerHTML = `<summary><button type="button" class="layer-drag-handle" aria-label="Trascina oggetto">☰</button><input type="checkbox" ${visible ? "checked" : ""} aria-label="Mostra oggetto"><span class="layer-manager-title">${escapeHtml(objectName)}</span><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")}<span class="layer-manager-count">›</span></summary><div class="layer-manager-details"></div>`;
|
||||
const checkbox = object.querySelector("input");
|
||||
checkbox.addEventListener("click", event => event.stopPropagation());
|
||||
checkbox.addEventListener("change", () => setManagedObjectVisible(record, checkbox.checked, true));
|
||||
@@ -1612,6 +1693,11 @@ function renderLayerManagerObject(record, index) {
|
||||
event.stopPropagation();
|
||||
removeManagedRecords([record], `'${objectName}'`);
|
||||
});
|
||||
object.querySelector(".layer-inline-lock")?.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
updateManagedProperties([record], value => { value._lmeLocked = !locked; });
|
||||
});
|
||||
object.querySelector(".layer-manager-title").addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -1740,7 +1826,7 @@ function renderManagedObjectTools(record) {
|
||||
<label>Note<textarea data-field="notes" rows="2" maxlength="1000" placeholder="Note tecniche">${escapeHtml(properties._lmeNotes || "")}</textarea></label>
|
||||
<label class="layer-photo-picker">Foto<input type="file" data-field="photo" accept="image/*"></label>
|
||||
${properties._lmePhoto ? `<img class="layer-photo-preview" src="${properties._lmePhoto}" alt="Foto allegata"><button type="button" data-action="remove-photo">Rimuovi foto</button>` : ""}
|
||||
<div class="layer-compact-actions"><button type="button" data-action="duplicate">Duplica</button><button type="button" data-action="numeric">Coordinate</button><button type="button" data-action="move">Sposta</button><button type="button" data-action="buffer">Buffer</button></div>`;
|
||||
<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</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);
|
||||
@@ -1764,8 +1850,10 @@ function renderManagedObjectTools(record) {
|
||||
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;
|
||||
@@ -1775,7 +1863,120 @@ function normalizedColor(value, fallback) {
|
||||
return /^#[0-9a-f]{6}$/i.test(String(value || "")) ? value : fallback;
|
||||
}
|
||||
|
||||
function editManagedAttributes(record) {
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||||
const properties = record.layer?.feature?.properties || {};
|
||||
const reserved = new Set(["name", "Name", "label", "LABEL"]);
|
||||
const current = Object.entries(properties)
|
||||
.filter(([key, value]) => !key.startsWith("_lme") && !reserved.has(key) && ["string", "number", "boolean"].includes(typeof value))
|
||||
.map(([key, value]) => `${key}=${value}`).join("\n");
|
||||
const value = window.prompt("Attributi, uno per riga nel formato chiave=valore\nEsempi: foglio, particella, proprietario, qualita, data_rilievo, stato", current);
|
||||
if (value === null) return;
|
||||
const parsed = {};
|
||||
value.split(/\r?\n/).forEach(line => {
|
||||
const separator = line.indexOf("=");
|
||||
if (separator < 1) return;
|
||||
const key = line.slice(0, separator).trim().replace(/[^a-zA-Z0-9_ -]/g, "_").slice(0, 60);
|
||||
const raw = line.slice(separator + 1).trim().slice(0, 500);
|
||||
if (key) parsed[key] = raw;
|
||||
});
|
||||
updateManagedProperties([record], target => {
|
||||
Object.keys(target).filter(key => !key.startsWith("_lme") && !reserved.has(key)).forEach(key => delete target[key]);
|
||||
Object.assign(target, parsed);
|
||||
});
|
||||
finishMapChange("Aggiornamento attributi");
|
||||
setInfo(`${Object.keys(parsed).length} attributi aggiornati.`);
|
||||
}
|
||||
|
||||
function utmFromLatLng(latitude, longitude) {
|
||||
const zone = Math.floor((longitude + 180) / 6) + 1;
|
||||
const central = (zone - 1) * 6 - 180 + 3;
|
||||
const a = 6378137; const eccSquared = 0.00669438; const k0 = 0.9996;
|
||||
const latRad = latitude * Math.PI / 180; const lonRad = longitude * Math.PI / 180; const lonOriginRad = central * Math.PI / 180;
|
||||
const eccPrimeSquared = eccSquared / (1 - eccSquared);
|
||||
const n = a / Math.sqrt(1 - eccSquared * Math.sin(latRad) ** 2);
|
||||
const t = Math.tan(latRad) ** 2; const c = eccPrimeSquared * Math.cos(latRad) ** 2;
|
||||
const aa = Math.cos(latRad) * (lonRad - lonOriginRad);
|
||||
const m = a * ((1 - eccSquared / 4 - 3 * eccSquared ** 2 / 64 - 5 * eccSquared ** 3 / 256) * latRad
|
||||
- (3 * eccSquared / 8 + 3 * eccSquared ** 2 / 32 + 45 * eccSquared ** 3 / 1024) * Math.sin(2 * latRad)
|
||||
+ (15 * eccSquared ** 2 / 256 + 45 * eccSquared ** 3 / 1024) * Math.sin(4 * latRad)
|
||||
- (35 * eccSquared ** 3 / 3072) * Math.sin(6 * latRad));
|
||||
const easting = k0 * n * (aa + (1 - t + c) * aa ** 3 / 6 + (5 - 18 * t + t ** 2 + 72 * c - 58 * eccPrimeSquared) * aa ** 5 / 120) + 500000;
|
||||
let northing = k0 * (m + n * Math.tan(latRad) * (aa ** 2 / 2 + (5 - t + 9 * c + 4 * c ** 2) * aa ** 4 / 24
|
||||
+ (61 - 58 * t + t ** 2 + 600 * c - 330 * eccPrimeSquared) * aa ** 6 / 720));
|
||||
if (latitude < 0) northing += 10000000;
|
||||
return { zone, band: latitude >= 0 ? "N" : "S", easting, northing };
|
||||
}
|
||||
|
||||
function recordCenter(record) {
|
||||
if (typeof record?.layer?.getBounds === "function" && record.layer.getBounds().isValid()) return record.layer.getBounds().getCenter();
|
||||
return record?.layer?.getLatLng?.() || null;
|
||||
}
|
||||
|
||||
function showCoordinateInspectorForRecord(record) {
|
||||
const point = recordCenter(record);
|
||||
if (!point) { setInfo("Coordinate non disponibili per questo oggetto."); return; }
|
||||
showCoordinateDialog(point, managedObjectEditableName(record) || "Oggetto selezionato");
|
||||
}
|
||||
|
||||
function showCoordinateInspector() {
|
||||
const records = [...lmeState.managerSelectedObjectIds].map(id => lmeState.managerRecords?.get(id)).filter(Boolean);
|
||||
const highlighted = lmeState.managerRecords?.get(lmeState.managerHighlightedObjectId);
|
||||
const record = highlighted || records[0];
|
||||
const point = record ? recordCenter(record) : lmeState.map.getCenter();
|
||||
showCoordinateDialog(point, record ? managedObjectEditableName(record) || "Oggetto selezionato" : "Centro mappa");
|
||||
}
|
||||
|
||||
function showCoordinateDialog(point, title) {
|
||||
const utm = utmFromLatLng(point.lat, point.lng);
|
||||
const text = `WGS84: ${point.lat.toFixed(7)}, ${point.lng.toFixed(7)}\nUTM ${utm.zone}${utm.band}: E ${utm.easting.toFixed(2)} m, N ${utm.northing.toFixed(2)} m`;
|
||||
showAppDialog(title, `<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 showObjectTable() {
|
||||
const records = layerManagerCategories().flatMap(category => managedRecordsForCategory(category));
|
||||
const body = records.map((record, index) => {
|
||||
const properties = record.layer?.feature?.properties || {};
|
||||
const details = managedObjectDetails(record.layer);
|
||||
return `<tr data-object-id="${escapeHtml(record.id)}"><td><button type="button" data-locate="${escapeHtml(record.id)}">⌖</button></td><td>${escapeHtml(managedObjectLabel(record, index))}</td><td>${escapeHtml(record.categoryKey)}</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-wrap"><table><thead><tr><th></th><th>Nome</th><th>Gruppo</th><th>Foglio</th><th>Particella</th><th>Misure</th></tr></thead><tbody>${body || "<tr><td colspan='6'>Nessun oggetto</td></tr>"}</tbody></table></div>`);
|
||||
const byId = new Map(records.map(record => [record.id, record]));
|
||||
dialog.querySelector("input").addEventListener("input", event => {
|
||||
const query = event.target.value.toLocaleLowerCase("it");
|
||||
dialog.querySelectorAll("tbody tr").forEach(row => { row.hidden = query && !row.textContent.toLocaleLowerCase("it").includes(query); });
|
||||
});
|
||||
dialog.querySelectorAll("[data-locate]").forEach(button => button.addEventListener("click", () => {
|
||||
const record = byId.get(button.dataset.locate); if (!record) return;
|
||||
dialog.remove(); highlightManagedObject(record);
|
||||
}));
|
||||
return records.length;
|
||||
}
|
||||
|
||||
function showAuditLog() {
|
||||
const rows = [...lmeState.auditLog].reverse().map(item => `<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: {} };
|
||||
@@ -1868,6 +2069,7 @@ function storedFeatureStyle(feature) {
|
||||
}
|
||||
|
||||
function duplicateManagedRecords(records) {
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||||
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) { setInfo("Sblocca gli oggetti da duplicare."); return; }
|
||||
rememberMapChange();
|
||||
records.forEach((record, index) => {
|
||||
@@ -1887,6 +2089,7 @@ function duplicateManagedRecords(records) {
|
||||
}
|
||||
|
||||
function moveManagedRecords(records) {
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||||
if (records.some(record => record.layer?.feature?.properties?._lmeLocked)) { setInfo("Sblocca gli oggetti da spostare."); return; }
|
||||
const north = Number(window.prompt("Spostamento verso nord in metri (negativo verso sud)", "0"));
|
||||
if (!Number.isFinite(north)) return;
|
||||
@@ -1903,6 +2106,7 @@ function moveManagedRecords(records) {
|
||||
}
|
||||
|
||||
function bufferManagedRecords(records) {
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||||
const meters = Number(window.prompt("Distanza buffer in metri", "5"));
|
||||
if (!Number.isFinite(meters) || meters <= 0) return;
|
||||
rememberMapChange();
|
||||
@@ -1917,6 +2121,7 @@ function bufferManagedRecords(records) {
|
||||
}
|
||||
|
||||
function unionManagedRecords(records) {
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||||
const polygons = records.filter(record => record.layer instanceof L.Polygon).map(record => record.layer.toGeoJSON());
|
||||
if (polygons.length < 2) { setInfo("Per unire seleziona almeno due aree o poligoni."); return; }
|
||||
try {
|
||||
@@ -1932,6 +2137,7 @@ function unionManagedRecords(records) {
|
||||
}
|
||||
|
||||
function splitManagedRecords(records) {
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||||
const polygonRecord = records.find(record => record.layer instanceof L.Polygon);
|
||||
const lineRecord = records.find(record => record.layer instanceof L.Polyline && !(record.layer instanceof L.Polygon));
|
||||
if (!polygonRecord || !lineRecord) { setInfo("Per dividere seleziona un poligono e una linea di taglio."); return; }
|
||||
@@ -1951,7 +2157,88 @@ function splitManagedRecords(records) {
|
||||
} catch (error) { setInfo(`Divisione non riuscita: ${error.message}`); }
|
||||
}
|
||||
|
||||
function selectedManagedRecords() {
|
||||
const current = lmeState.managerRecords || new Map();
|
||||
return [...lmeState.managerSelectedObjectIds].map(id => current.get(id)).filter(Boolean);
|
||||
}
|
||||
|
||||
function createHoleFromSelected() {
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
|
||||
const polygons = selectedManagedRecords().filter(record => record.layer instanceof L.Polygon);
|
||||
if (polygons.length !== 2) { setInfo("Seleziona esattamente due poligoni: prima l'area esterna e poi l'area da escludere."); return false; }
|
||||
try {
|
||||
const ordered = [...polygons].sort((left, right) => turf.area(right.layer.toGeoJSON()) - turf.area(left.layer.toGeoJSON()));
|
||||
const outer = ordered[0].layer.toGeoJSON();
|
||||
const inner = ordered[1].layer.toGeoJSON();
|
||||
if (!turf.booleanWithin(inner, outer)) throw new Error("il poligono interno deve essere completamente contenuto nell'area esterna");
|
||||
const result = turf.difference(turf.featureCollection([outer, inner]));
|
||||
if (!result) throw new Error("nessun risultato geometrico");
|
||||
rememberMapChange();
|
||||
result.properties = { ...(outer.properties || {}), name: `${managedObjectLabel(ordered[0], 0)} con area esclusa`, _lmeManagerId: undefined };
|
||||
addDrawingFeature(result, true);
|
||||
ordered.forEach(removeRecordWithoutPrompt);
|
||||
lmeState.managerSelectedObjectIds.clear();
|
||||
finishManagedGeometryChange("Poligono con foro creato correttamente.");
|
||||
return true;
|
||||
} catch (error) { setInfo(`Creazione foro non riuscita: ${error.message}`); return false; }
|
||||
}
|
||||
|
||||
function validateAndRepairSelected() {
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return "Progetto in sola lettura"; }
|
||||
const records = selectedManagedRecords();
|
||||
if (!records.length) { setInfo("Seleziona gli oggetti da controllare."); return "Nessuna selezione"; }
|
||||
let valid = 0; let repaired = 0; let rejected = 0;
|
||||
rememberMapChange();
|
||||
records.forEach((record, index) => {
|
||||
try {
|
||||
let feature = turf.cleanCoords(record.layer.toGeoJSON(), { mutate: false });
|
||||
if (feature.geometry?.type === "Polygon" && typeof turf.booleanValid === "function" && !turf.booleanValid(feature)) {
|
||||
const parts = turf.unkinkPolygon(feature).features;
|
||||
if (!parts.length) throw new Error("geometria non riparabile");
|
||||
removeRecordWithoutPrompt(record);
|
||||
parts.forEach((part, partIndex) => {
|
||||
part.properties = { ...(feature.properties || {}), name: `${managedObjectLabel(record, index)} - riparato ${partIndex + 1}`, _lmeManagerId: undefined };
|
||||
addDrawingFeature(part);
|
||||
});
|
||||
repaired++;
|
||||
} else {
|
||||
if (JSON.stringify(feature.geometry) !== JSON.stringify(record.layer.toGeoJSON().geometry)) {
|
||||
replaceManagedLayer(record, feature); repaired++;
|
||||
} else valid++;
|
||||
}
|
||||
} catch { rejected++; }
|
||||
});
|
||||
lmeState.managerSelectedObjectIds.clear();
|
||||
finishManagedGeometryChange(`Controllo geometrie: ${valid} valide, ${repaired} riparate, ${rejected} non riparabili.`);
|
||||
return `${valid} valide, ${repaired} riparate, ${rejected} non riparabili`;
|
||||
}
|
||||
|
||||
function compareSelectedGeometries() {
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
|
||||
const polygons = selectedManagedRecords().filter(record => record.layer instanceof L.Polygon);
|
||||
if (polygons.length !== 2) { setInfo("Seleziona esattamente due poligoni da confrontare."); return false; }
|
||||
try {
|
||||
const first = polygons[0].layer.toGeoJSON(); const second = polygons[1].layer.toGeoJSON();
|
||||
const onlyFirst = turf.difference(turf.featureCollection([first, second]));
|
||||
const onlySecond = turf.difference(turf.featureCollection([second, first]));
|
||||
const overlap = turf.intersect(turf.featureCollection([first, second]));
|
||||
rememberMapChange();
|
||||
const addResult = (feature, name, color) => {
|
||||
if (!feature) return;
|
||||
feature.properties = { name, _lmeColor: color, _lmeFillColor: color, _lmeFillOpacity: 0.38, _lmeManagerId: undefined };
|
||||
addDrawingFeature(feature);
|
||||
};
|
||||
addResult(onlyFirst, `Solo ${managedObjectLabel(polygons[0], 0)}`, "#ef4444");
|
||||
addResult(onlySecond, `Solo ${managedObjectLabel(polygons[1], 1)}`, "#3b82f6");
|
||||
addResult(overlap, "Sovrapposizione", "#16a34a");
|
||||
const firstArea = onlyFirst ? turf.area(onlyFirst) : 0; const secondArea = onlySecond ? turf.area(onlySecond) : 0;
|
||||
finishManagedGeometryChange(`Confronto creato: eccedenza ${formatArea(firstArea)}, mancanza ${formatArea(secondArea)}.`);
|
||||
return true;
|
||||
} catch (error) { setInfo(`Confronto non riuscito: ${error.message}`); return false; }
|
||||
}
|
||||
|
||||
function numericEditManagedRecord(record) {
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return; }
|
||||
if (record.layer?.feature?.properties?._lmeLocked) { setInfo("Sblocca prima l'oggetto."); return; }
|
||||
if (record.layer instanceof L.Circle) {
|
||||
const center = record.layer.getLatLng();
|
||||
@@ -2004,20 +2291,76 @@ function rememberMapChange() {
|
||||
}
|
||||
|
||||
function finishMapChange() {
|
||||
if (!lmeState.autoSaveEnabled || lmeState.historyBusy || !lmeState.map) return;
|
||||
try { localStorage.setItem("lmeAutosaveV3", createProjectSnapshot(true)); } catch { }
|
||||
if (lmeState.historyBusy || !lmeState.map) return;
|
||||
const action = arguments[0] || "Modifica mappa";
|
||||
lmeState.isDirty = true;
|
||||
lmeState.auditLog.push({ at: new Date().toISOString(), action });
|
||||
if (lmeState.auditLog.length > 250) lmeState.auditLog.shift();
|
||||
if (lmeState.autoSaveEnabled) {
|
||||
try { localStorage.setItem("lmeAutosaveV3", createProjectSnapshot(true)); } catch { }
|
||||
}
|
||||
window.clearTimeout(lmeState.changeNotifyTimer);
|
||||
lmeState.changeNotifyTimer = window.setTimeout(() => {
|
||||
void lmeState.dotNetReference?.invokeMethodAsync("OnMapChanged", action);
|
||||
}, 450);
|
||||
}
|
||||
|
||||
function getHistoryState() {
|
||||
return { canUndo: lmeState.undoStack.length > 0, canRedo: lmeState.redoStack.length > 0, dirty: lmeState.isDirty };
|
||||
}
|
||||
|
||||
function markProjectSaved() {
|
||||
lmeState.isDirty = false;
|
||||
void lmeState.dotNetReference?.invokeMethodAsync("OnProjectSaved");
|
||||
}
|
||||
|
||||
function confirmDiscardChanges(actionLabel) {
|
||||
return !lmeState.isDirty || window.confirm(`Ci sono modifiche non salvate. Continuare e ${actionLabel || "abbandonarle"}?`);
|
||||
}
|
||||
|
||||
function setProjectReadOnly(enabled) {
|
||||
lmeState.readOnly = Boolean(enabled);
|
||||
document.querySelector(".map-app")?.classList.toggle("project-readonly", lmeState.readOnly);
|
||||
if (lmeState.readOnly) setDrawToolsVisible(false);
|
||||
refreshLayerManager();
|
||||
setInfo(lmeState.readOnly ? "Progetto aperto in sola lettura. Duplicalo per modificarlo." : "Progetto modificabile.");
|
||||
}
|
||||
|
||||
function undoMapChange() {
|
||||
if (!lmeState.undoStack.length) { setInfo("Nessuna operazione da annullare."); return; }
|
||||
lmeState.redoStack.push(createProjectSnapshot(true));
|
||||
restoreHistorySnapshot(lmeState.undoStack.pop(), "Operazione annullata.");
|
||||
lmeState.isDirty = true;
|
||||
}
|
||||
|
||||
function redoMapChange() {
|
||||
if (!lmeState.redoStack.length) { setInfo("Nessuna operazione da ripristinare."); return; }
|
||||
lmeState.undoStack.push(createProjectSnapshot(true));
|
||||
restoreHistorySnapshot(lmeState.redoStack.pop(), "Operazione ripristinata.");
|
||||
lmeState.isDirty = true;
|
||||
}
|
||||
|
||||
function restoreLastTrashItem() {
|
||||
if (lmeState.readOnly) { setInfo("Il progetto e' in sola lettura."); return false; }
|
||||
const entry = lmeState.trash.pop();
|
||||
if (!entry) { setInfo("Il cestino e' vuoto."); return false; }
|
||||
rememberMapChange();
|
||||
entry.items.forEach(item => {
|
||||
if (item.categoryKey === "imported") addImportedFeatures({ type: "FeatureCollection", features: [item.feature] });
|
||||
else if (item.categoryKey === "importedSheets") addImportedSheetFeatures({ type: "FeatureCollection", features: [item.feature] });
|
||||
else addDrawingFeature(item.feature);
|
||||
});
|
||||
refreshLayerManager();
|
||||
finishManagedGeometryChange(`Ripristinati ${entry.items.length} oggetti dal cestino.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function emptyTrash() {
|
||||
if (!lmeState.trash.length) { setInfo("Il cestino e' gia vuoto."); return 0; }
|
||||
if (!window.confirm(`Eliminare definitivamente ${lmeState.trash.reduce((sum, entry) => sum + entry.items.length, 0)} oggetti dal cestino?`)) return lmeState.trash.length;
|
||||
lmeState.trash = [];
|
||||
setInfo("Cestino svuotato.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
function restoreHistorySnapshot(snapshot, message) {
|
||||
@@ -2597,7 +2940,7 @@ function addImportedFeatures(geojson) {
|
||||
fillColor: "#ffffff",
|
||||
fillOpacity: 0.9
|
||||
}),
|
||||
onEachFeature: (feature, layer) => { bindStoredFeaturePopup(feature, layer); applyStyleFromProperties(layer); }
|
||||
onEachFeature: (feature, layer) => { bindStoredFeaturePopup(feature, layer); applyStyleFromProperties(layer); bindManagedMapSelection(layer, "imported", lmeState.imported); }
|
||||
}).addTo(lmeState.imported);
|
||||
layer.lmeManagerLabel = managedGeoJsonLabel(geojson, "Dati importati");
|
||||
return layer;
|
||||
@@ -2614,7 +2957,7 @@ function addImportedSheetFeatures(geojson) {
|
||||
fillColor: "#ffffff",
|
||||
fillOpacity: 0.85
|
||||
}),
|
||||
onEachFeature: (feature, layer) => { bindStoredFeaturePopup(feature, layer); applyStyleFromProperties(layer); }
|
||||
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;
|
||||
@@ -2717,6 +3060,10 @@ function bindDrawingEvents() {
|
||||
return;
|
||||
}
|
||||
lmeState.map.on(L.Draw.Event.CREATED, event => {
|
||||
if (lmeState.readOnly) {
|
||||
setInfo("Il progetto e' in sola lettura.");
|
||||
return;
|
||||
}
|
||||
snapDrawingLayer(event.layer);
|
||||
annotateDrawingLayer(event.layer, event.layerType);
|
||||
lmeState.drawings.addLayer(event.layer);
|
||||
@@ -3047,7 +3394,37 @@ function bindDrawingLayerSelection(layer) {
|
||||
event.originalEvent._lmeDrawingSelected = true;
|
||||
L.DomEvent.stopPropagation(event.originalEvent);
|
||||
}
|
||||
const id = managedLayerId(layer, "drawings");
|
||||
if (event.originalEvent?.ctrlKey || event.originalEvent?.metaKey || event.originalEvent?.shiftKey) {
|
||||
if (lmeState.managerSelectedObjectIds.has(id)) lmeState.managerSelectedObjectIds.delete(id);
|
||||
else lmeState.managerSelectedObjectIds.add(id);
|
||||
} else {
|
||||
lmeState.managerSelectedObjectIds.clear();
|
||||
lmeState.managerSelectedObjectIds.add(id);
|
||||
}
|
||||
selectDrawingLayer(layer);
|
||||
refreshLayerManager();
|
||||
});
|
||||
}
|
||||
|
||||
function bindManagedMapSelection(layer, categoryKey, parent) {
|
||||
if (layer.lmeManagedMapSelectionBound) return;
|
||||
layer.lmeManagedMapSelectionBound = true;
|
||||
layer.on("click", event => {
|
||||
if (Date.now() < lmeState.ignoreMapClickUntil) return;
|
||||
lmeState.ignoreMapClickUntil = Date.now() + 600;
|
||||
if (event.originalEvent) {
|
||||
event.originalEvent._lmeDrawingSelected = true;
|
||||
L.DomEvent.stopPropagation(event.originalEvent);
|
||||
}
|
||||
const record = { id: managedLayerId(layer, categoryKey), categoryKey, layer, parent, source: String(layer?.feature?.properties?._lmeSource || "Progetto corrente") };
|
||||
const multiple = event.originalEvent?.ctrlKey || event.originalEvent?.metaKey || event.originalEvent?.shiftKey;
|
||||
if (!multiple) lmeState.managerSelectedObjectIds.clear();
|
||||
if (multiple && lmeState.managerSelectedObjectIds.has(record.id)) lmeState.managerSelectedObjectIds.delete(record.id);
|
||||
else lmeState.managerSelectedObjectIds.add(record.id);
|
||||
highlightManagedObject(record);
|
||||
refreshLayerManager();
|
||||
setInfo(`Selezionato dalla mappa: ${managedObjectEditableName(record) || "oggetto"}.`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3103,15 +3480,32 @@ function clearDrawings() {
|
||||
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)));
|
||||
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);
|
||||
@@ -3448,7 +3842,7 @@ function circleToPolygon(center, radiusMeters) {
|
||||
function createProjectSnapshot(includeCadastralParcels) {
|
||||
const center = lmeState.map.getCenter();
|
||||
return JSON.stringify({
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
view: {
|
||||
latitude: center.lat,
|
||||
longitude: center.lng,
|
||||
@@ -3465,7 +3859,9 @@ function createProjectSnapshot(includeCadastralParcels) {
|
||||
trail: lmeState.gpsTrailPoints.map(point => [
|
||||
Number(point.lat ?? point[0]),
|
||||
Number(point.lng ?? point[1])
|
||||
])
|
||||
]),
|
||||
trailStartedAt: lmeState.gpsTrailStartedAt,
|
||||
auditLog: [...lmeState.auditLog]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3548,6 +3944,8 @@ function loadProjectSnapshot(raw, projectName) {
|
||||
}).addTo(lmeState.selectedParcelsLayer);
|
||||
|
||||
lmeState.gpsTrailPoints = Array.isArray(data.trail) ? data.trail : [];
|
||||
lmeState.gpsTrailStartedAt = data.trailStartedAt || null;
|
||||
lmeState.auditLog = Array.isArray(data.auditLog) ? data.auditLog.slice(-250) : [];
|
||||
lmeState.gpsTrail.setLatLngs(lmeState.gpsTrailPoints);
|
||||
if (projectDisplayOptions) {
|
||||
applyProjectDisplayOptions(projectDisplayOptions);
|
||||
@@ -3584,6 +3982,12 @@ function loadProjectSnapshot(raw, projectName) {
|
||||
: "";
|
||||
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}`);
|
||||
@@ -3798,6 +4202,136 @@ function updateStoredFeaturePopup(layer, sourceProperties) {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -3813,7 +4347,7 @@ function setBaseLayer(name) {
|
||||
topographic: "Topografica Esri",
|
||||
terrain: "OpenTopoMap",
|
||||
carto: "CARTO Voyager",
|
||||
offline: "Offline, solo dati vettoriali",
|
||||
offline: "Mappe salvate offline",
|
||||
"azure-road": "Strade Azure Maps",
|
||||
"azure-satellite": "Satellite Azure Maps",
|
||||
"google-satellite": "Satellite Google Maps",
|
||||
@@ -3973,14 +4507,10 @@ function setAzureAttribution(text) {
|
||||
return;
|
||||
}
|
||||
lmeState.azureAttribution = escapeHtml(text);
|
||||
lmeState.map.attributionControl.addAttribution(lmeState.azureAttribution);
|
||||
}
|
||||
|
||||
function clearAzureAttribution() {
|
||||
if (lmeState.azureAttribution) {
|
||||
lmeState.map.attributionControl.removeAttribution(lmeState.azureAttribution);
|
||||
lmeState.azureAttribution = null;
|
||||
}
|
||||
lmeState.azureAttribution = null;
|
||||
}
|
||||
|
||||
function htmlToText(value) {
|
||||
@@ -4220,9 +4750,26 @@ function centerOnGps() {
|
||||
|
||||
function toggleTrail(enabled) {
|
||||
lmeState.gpsTrailEnabled = enabled;
|
||||
if (!enabled) {
|
||||
setInfo("Registrazione percorso disattivata.");
|
||||
}
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user