Rilascia gestione completa progetti e aggiornamenti 0.4.0

This commit is contained in:
livio committed 2026-09-24 17:21:25 +02:00
1 parent 620a4bec26
commit 205a520708
10 files changed
+1366 -65

No files matched your search

+193 -1
View File
@@ -7,7 +7,7 @@
--accent: #14705e;
--accent-strong: #0c5749;
--warning: #a34b08;
--nav-height: 64px;
--nav-height: 0px;
}
html,
@@ -135,6 +135,70 @@ input[type="checkbox"] {
flex: 0 0 auto;
}
.map-icon-command,
.map-menu-button {
width: 38px;
min-width: 38px;
min-height: 38px;
padding: 0;
background: #fff;
color: var(--ink);
font-size: 22px;
line-height: 1;
}
.map-menu-button.is-active {
background: #ccebe3;
color: var(--accent-strong);
}
.app-status-line i {
display: inline-block;
width: 8px;
height: 8px;
margin-left: 5px;
border-radius: 50%;
background: #fbbf24;
}
.map-navigation-menu {
position: absolute;
top: 70px;
right: 12px;
z-index: 1150;
display: grid;
width: min(300px, calc(100vw - 24px));
max-height: calc(100vh - 84px);
overflow-y: auto;
border: 1px solid var(--line);
border-radius: 8px;
padding: 7px;
background: rgba(255, 255, 255, 0.99);
box-shadow: 0 5px 22px rgba(0, 0, 0, 0.3);
}
.map-navigation-menu > button {
min-height: 40px;
border-bottom: 1px solid #e5eaed;
border-radius: 0;
background: transparent;
color: var(--ink);
text-align: left;
}
.menu-credits {
display: grid;
gap: 3px;
padding: 10px 12px 5px;
color: var(--muted);
font-size: 10px;
line-height: 1.35;
}
.menu-credits a {
color: var(--accent-strong);
}
.layer-button.is-active {
background: #ccebe3;
color: var(--accent-strong);
@@ -558,6 +622,129 @@ input[type="checkbox"] {
font-weight: 400;
}
.layer-inline-lock {
grid-area: visibility;
justify-self: center;
width: 28px;
min-width: 28px;
min-height: 24px;
margin-left: 26px;
padding: 0;
background: transparent;
color: var(--ink);
font-size: 15px;
}
.layer-inline-lock.is-locked {
background: #fff2cc;
}
.project-readonly .leaflet-draw,
.project-readonly .layer-rename-button,
.project-readonly .layer-remove-button,
.project-readonly .layer-inline-lock,
.project-readonly .layer-object-tools,
.project-readonly .layer-group-tools,
.project-readonly .layer-batch-actions {
pointer-events: none;
opacity: 0.5;
}
.lme-app-dialog {
position: absolute;
inset: 0;
z-index: 1500;
display: grid;
place-items: center;
padding: 16px;
background: rgba(15, 23, 28, 0.48);
}
.lme-app-dialog > section {
display: flex;
flex-direction: column;
width: min(820px, 96vw);
max-height: 88vh;
overflow: hidden;
border-radius: 8px;
background: #fff;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.32);
}
.lme-app-dialog header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
}
.lme-app-dialog header button {
width: 36px;
min-width: 36px;
min-height: 34px;
padding: 0;
background: #e9eef0;
color: var(--ink);
font-size: 22px;
}
.lme-app-dialog-body {
overflow: auto;
padding: 12px;
}
.object-table-wrap {
overflow: auto;
}
.object-table-wrap table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.object-table-wrap th,
.object-table-wrap td {
padding: 7px;
border-bottom: 1px solid var(--line);
text-align: left;
white-space: nowrap;
}
.object-table-wrap button {
width: 30px;
min-width: 30px;
min-height: 30px;
padding: 0;
}
.object-table-search,
.coordinate-result {
display: grid;
gap: 8px;
margin-bottom: 10px;
}
.release-notes {
max-height: 220px;
overflow: auto;
margin: 8px 0 0;
padding: 9px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--ink);
font-family: inherit;
font-size: 11px;
white-space: pre-wrap;
}
.lme-offline-image-tile {
width: 256px;
height: 256px;
}
.layer-manager-count {
grid-area: count;
justify-self: end;
@@ -1171,6 +1358,11 @@ footer {
width: 50vw;
}
.map-navigation-menu {
top: 66px;
right: 8px;
}
.tool-sheet {
top: 10%;
bottom: var(--nav-height);
+577 -30
View File
@@ -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) {