Editor piantine: bozza automatica e pubblicazione con anteprima

- Il disegno è bozza e si salva da solo; i dati del territorio si
  modificano dall'editor e valgono subito nel gestionale.
- Un solo "Pubblica" (immagine + PDF), uguale in editor e gestione:
  anteprima preparata in coda su disco privato, conferma obbligatoria,
  rifiutata se nel frattempo è cambiato qualcosa di stampato.
- Stati Bozza / Pubblicata / Modifiche da pubblicare, calcolati
  sull'impronta di confine e dati stampati e sul PDF in uso.
- Gestione riscritta: stato, ricerca, filtri, storico paginato.
- Editor ridisegnato a tre zone, utilizzabile anche su tablet e telefono.
- Avvisi via evento (trait Avvisa + <x-avvisi>).
- .gitattributes: script shell sempre LF, altrimenti il container
  locale su Windows non parte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 12:08:34 +02:00
parent d433c9392d
commit 056d95cfcb
25 changed files with 3084 additions and 980 deletions

View File

@@ -1,10 +1,22 @@
// Editor delle piantine (pagina /piantine/editor, solo amministratore).
//
// Mappa Leaflet su tessere OpenStreetMap: mostra l'area del KML, le piantine
// già salvate e permette di disegnarne di nuove con leaflet-draw. Parla con il
// componente Livewire attraverso eventi:
// JS → Livewire: 'poligono-disegnato', 'piantina-selezionata-mappa'
// Livewire → JS: 'piantine-dati-aggiornati'
// Mappa Leaflet su tessere OpenStreetMap con l'area del KML e le piantine, più
// l'elenco con ricerca a sinistra. Disegno e modifica dei vertici usano i
// gestori di leaflet-draw, comandati da una barra di strumenti nostra: pulsanti
// grandi e con il testo, che si usano anche al tablet.
//
// Modalità, sempre esplicite:
// vista si guarda, si sceglie una piantina (clic sulla mappa o in elenco)
// disegno si traccia una piantina NUOVA
// ridisegno si traccia da capo il confine di quella aperta (con conferma)
// vertici si trascinano i vertici di quella aperta
//
// Con il componente Livewire si parla per eventi:
// JS → Livewire: 'piantina-selezionata-mappa', 'geometria-modificata'
// Livewire → JS: 'piantina-aggiornata', 'piantina-rimossa',
// 'piantina-selezionata', 'editor-disegna', 'editor-indirizzo'
// I dati di tutte le piantine si chiedono una volta sola ($wire.datiMappa());
// dopo arriva solo la piantina cambiata.
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
@@ -12,12 +24,13 @@ import 'leaflet-draw';
import 'leaflet-draw/dist/leaflet.draw.css';
const STILE_AREA = { color: '#4a45a8', weight: 2, dashArray: '8 6', fill: false, interactive: false };
// Una piantina collegata a un territorio è "a posto"; una scollegata è un
// lavoro ancora aperto, e si vede senza dover aprire il pannello.
const STILE_COLLEGATA = { color: '#8a8578', weight: 2, fillColor: '#8a8578', fillOpacity: 0.1 };
const STILE_SCOLLEGATA = { color: '#b4762a', weight: 2, dashArray: '6 5', fillColor: '#c98b3a', fillOpacity: 0.1 };
const STILE_SELEZIONATA = { color: '#4a45a8', weight: 3, fillColor: '#605cb2', fillOpacity: 0.18 };
const STILE_IN_ATTESA = { color: '#b3352c', weight: 3, fillColor: '#d0504a', fillOpacity: 0.15 };
const STILI = {
pubblicata: { color: '#2f6f4e', weight: 2, fillColor: '#2f6f4e', fillOpacity: 0.08 },
modificata: { color: '#a06a22', weight: 2, fillColor: '#c98b3a', fillOpacity: 0.1 },
bozza: { color: '#a06a22', weight: 2, dashArray: '6 5', fillColor: '#c98b3a', fillOpacity: 0.1 },
};
const STILE_SELEZIONATA = { color: '#4a45a8', weight: 3, dashArray: null, fillColor: '#605cb2', fillOpacity: 0.18 };
const STILE_DISEGNO = { color: '#b3352c', weight: 3, fillColor: '#d0504a', fillOpacity: 0.15 };
function initPiantine() {
const contenitore = document.getElementById('piantine-map');
@@ -26,143 +39,294 @@ function initPiantine() {
}
contenitore.dataset.inizializzato = '1';
let dati = { area: null, piantine: [], selezionata: null };
try {
dati = JSON.parse(document.getElementById('piantine-dati').textContent);
} catch {
// si parte comunque con la mappa vuota
}
const radice = contenitore.closest('[wire\\:id]');
const componente = () => window.Livewire.find(radice.getAttribute('wire:id'));
// Etichette in italiano per la barra di disegno.
L.drawLocal.draw.toolbar.buttons.polygon = 'Disegna il confine di una piantina';
L.drawLocal.draw.toolbar.actions = { title: 'Annulla il disegno', text: 'Annulla' };
L.drawLocal.draw.toolbar.finish = { title: 'Concludi il disegno', text: 'Concludi' };
L.drawLocal.draw.toolbar.undo = { title: 'Rimuovi l\'ultimo punto', text: 'Ultimo punto' };
L.drawLocal.draw.handlers.polygon.tooltip = {
start: 'Clicca sulla mappa per iniziare il confine.',
cont: 'Continua a cliccare lungo il confine.',
end: 'Clicca sul primo punto per chiudere il poligono.',
start: 'Tocca o clicca sulla mappa per il primo punto del confine.',
cont: 'Continua lungo il confine.',
end: 'Chiudi sul primo punto, o premi «Concludi».',
};
L.drawLocal.edit.handlers.edit.tooltip = { text: 'Trascina i vertici per correggere il confine.', subtext: '' };
const mappa = L.map(contenitore, { zoomControl: true });
L.control.scale({ imperial: false }).addTo(mappa);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(mappa);
const gruppo = L.featureGroup().addTo(mappa);
const layers = new Map(); // mappaId → { dati, layer }
let layerArea = null;
const layerSalvate = L.featureGroup().addTo(mappa);
let layerInAttesa = null;
let selezionata = null;
let modo = 'vista';
let gestoreDisegno = null;
let poligonoInModifica = null;
mappa.addControl(new L.Control.Draw({
draw: {
polygon: { allowIntersection: false, showArea: false, shapeOptions: STILE_IN_ATTESA },
polyline: false,
rectangle: false,
circle: false,
circlemarker: false,
marker: false,
},
edit: false,
}));
// ── Disegno delle piantine ──────────────────────────────────────────────
function stileDi(piantina, selezionata) {
if (selezionata) return STILE_SELEZIONATA;
return piantina.collegata ? STILE_COLLEGATA : STILE_SCOLLEGATA;
function stileDi(dati) {
if (dati.mappaId === selezionata) return STILE_SELEZIONATA;
return { dashArray: null, ...(STILI[dati.stato] ?? STILI.bozza) };
}
function ridisegna(nuoviDati, adattaVista) {
dati = nuoviDati;
function disegna(dati) {
rimuovi(dati.mappaId, false);
if (!dati.geometry) return;
if (layerArea) {
mappa.removeLayer(layerArea);
layerArea = null;
let layer;
try {
layer = L.geoJSON({ type: 'Feature', properties: {}, geometry: dati.geometry }, { style: stileDi(dati) });
} catch {
return; // una geometria rovinata non deve fermare le altre
}
layer.bindTooltip(`${dati.etichetta} · ${dati.statoEtichetta}`, { sticky: true });
layer.on('click', () => {
if (modo !== 'vista') return;
componente().dispatch('piantina-selezionata-mappa', { mappaId: dati.mappaId });
});
gruppo.addLayer(layer);
layers.set(dati.mappaId, { dati, layer });
}
function rimuovi(mappaId, aggiornaElenco = true) {
const voce = layers.get(mappaId);
if (voce) {
gruppo.removeLayer(voce.layer);
layers.delete(mappaId);
}
if (aggiornaElenco) disegnaElenco();
}
function ristila() {
for (const { dati, layer } of layers.values()) {
layer.setStyle(stileDi(dati));
}
}
function inquadra(mappaId) {
const voce = layers.get(mappaId);
if (voce) mappa.fitBounds(voce.layer.getBounds().pad(0.25));
}
// ── Elenco con ricerca ──────────────────────────────────────────────────
const elenco = document.getElementById('piantine-elenco');
const cerca = document.getElementById('piantine-cerca');
function disegnaElenco() {
if (!elenco) return;
const filtro = (cerca?.value ?? '').trim().toLowerCase();
const voci = [...layers.values()].map((v) => v.dati)
.filter((d) => !filtro || d.etichetta.toLowerCase().includes(filtro))
.sort((a, b) => a.etichetta.localeCompare(b.etichetta, 'it', { numeric: true }));
elenco.replaceChildren(...voci.map((d) => {
const riga = document.createElement('li');
const pulsante = document.createElement('button');
pulsante.type = 'button';
pulsante.className = 'tm-editor-elenco-voce' + (d.mappaId === selezionata ? ' is-selezionata' : '');
pulsante.setAttribute('aria-current', d.mappaId === selezionata ? 'true' : 'false');
const nome = document.createElement('span');
nome.textContent = d.etichetta;
const stato = document.createElement('span');
stato.className = 'tm-editor-elenco-stato tm-stato-' + d.stato;
stato.textContent = d.statoEtichetta;
pulsante.append(nome, stato);
pulsante.addEventListener('click', () => {
if (modo !== 'vista') return;
componente().dispatch('piantina-selezionata-mappa', { mappaId: d.mappaId });
});
riga.append(pulsante);
return riga;
}));
if (!voci.length) {
const vuoto = document.createElement('li');
vuoto.className = 'tm-editor-elenco-vuoto';
vuoto.textContent = filtro ? 'Nessuna piantina trovata.' : 'Nessuna piantina ancora.';
elenco.append(vuoto);
}
}
cerca?.addEventListener('input', disegnaElenco);
// ── Barra degli strumenti ───────────────────────────────────────────────
const barra = document.getElementById('piantine-strumenti');
function pulsante(testo, azione, principale = false) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'tm-strumento' + (principale ? ' tm-strumento-principale' : '');
b.textContent = testo;
b.addEventListener('click', azione);
return b;
}
function aggiornaBarra() {
if (!barra) return;
const comandi = [];
if (modo === 'vista') {
comandi.push(pulsante('Disegna nuova piantina', () => avviaDisegno(false), selezionata === null));
if (selezionata !== null && layers.has(selezionata)) {
comandi.push(pulsante('Modifica vertici', avviaVertici));
comandi.push(pulsante('Ridisegna confine', () => {
if (window.confirm('Ridisegnare da capo il confine di questa piantina? Quello attuale viene sostituito.')) {
avviaDisegno(true);
}
}));
}
} else if (modo === 'disegno' || modo === 'ridisegno') {
const titolo = document.createElement('span');
titolo.className = 'tm-strumenti-modo';
titolo.textContent = modo === 'disegno' ? 'Nuova piantina' : 'Nuovo confine';
comandi.push(titolo);
comandi.push(pulsante('Togli ultimo punto', () => gestoreDisegno?.deleteLastVertex()));
comandi.push(pulsante('Concludi', () => gestoreDisegno?.completeShape(), true));
comandi.push(pulsante('Annulla', fermaDisegno));
} else if (modo === 'vertici') {
const titolo = document.createElement('span');
titolo.className = 'tm-strumenti-modo';
titolo.textContent = 'Trascina i vertici';
comandi.push(titolo);
comandi.push(pulsante('Fine modifica', fermaVertici, true));
}
barra.replaceChildren(...comandi);
}
// ── Disegno ─────────────────────────────────────────────────────────────
let ridisegno = false;
function avviaDisegno(diQuellaAperta) {
fermaVertici(false);
ridisegno = diQuellaAperta;
modo = diQuellaAperta ? 'ridisegno' : 'disegno';
gestoreDisegno = new L.Draw.Polygon(mappa, { allowIntersection: false, showArea: false, shapeOptions: STILE_DISEGNO });
gestoreDisegno.enable();
aggiornaBarra();
}
function fermaDisegno() {
gestoreDisegno?.disable();
gestoreDisegno = null;
modo = 'vista';
aggiornaBarra();
}
mappa.on(L.Draw.Event.CREATED, (evento) => {
const geometry = evento.layer.toGeoJSON().geometry;
const nuova = !ridisegno;
fermaDisegno();
componente().dispatch('geometria-modificata', { geometry, nuova });
});
// Il gestore si spegne da solo anche con Esc: la barra deve seguirlo.
mappa.on(L.Draw.Event.DRAWSTOP, () => {
if (modo === 'disegno' || modo === 'ridisegno') {
gestoreDisegno = null;
modo = 'vista';
aggiornaBarra();
}
});
// ── Modifica dei vertici ────────────────────────────────────────────────
function avviaVertici() {
const voce = layers.get(selezionata);
if (!voce) return;
poligonoInModifica = voce.layer.getLayers()[0];
if (!poligonoInModifica?.editing) return;
poligonoInModifica.editing.enable();
modo = 'vertici';
aggiornaBarra();
}
function fermaVertici(salva = true) {
if (!poligonoInModifica) {
if (modo === 'vertici') { modo = 'vista'; aggiornaBarra(); }
return;
}
const poligono = poligonoInModifica;
poligonoInModifica = null;
const modificato = poligono.edited;
poligono.editing.disable();
modo = 'vista';
aggiornaBarra();
if (salva && modificato) {
componente().dispatch('geometria-modificata', { geometry: poligono.toGeoJSON().geometry, nuova: false });
}
}
// ── Eventi dal server ───────────────────────────────────────────────────
function seleziona(mappaId, centra) {
if (modo === 'vertici') fermaVertici();
selezionata = mappaId ?? null;
ristila();
disegnaElenco();
aggiornaBarra();
if (centra && selezionata !== null) inquadra(selezionata);
}
function registra() {
window.Livewire.on('piantina-aggiornata', ({ piantina }) => {
disegna(piantina);
disegnaElenco();
aggiornaBarra();
});
window.Livewire.on('piantina-rimossa', ({ mappaId }) => {
if (selezionata === mappaId) selezionata = null;
rimuovi(mappaId);
aggiornaBarra();
});
window.Livewire.on('piantina-selezionata', ({ mappaId, inquadra: centra }) => seleziona(mappaId, centra));
window.Livewire.on('editor-disegna', () => avviaDisegno(false));
window.Livewire.on('editor-indirizzo', ({ url }) => {
if (window.location.href !== url) window.history.replaceState(window.history.state, '', url);
});
}
async function carica() {
const dati = await componente().datiMappa();
if (dati.area) {
layerArea = L.geoJSON(dati.area, { style: STILE_AREA, interactive: false }).addTo(mappa);
}
selezionata = dati.selezionata ?? null;
dati.piantine.forEach(disegna);
disegnaElenco();
aggiornaBarra();
layerSalvate.clearLayers();
let layerSelezionata = null;
for (const piantina of dati.piantine) {
const selezionata = dati.selezionata === piantina.mappaId;
const layer = L.geoJSON(
{ type: 'Feature', properties: {}, geometry: piantina.geometry },
{ style: stileDi(piantina, selezionata) }
);
layer.bindTooltip(piantina.etichetta, { sticky: true });
layer.on('click', () => {
window.Livewire.dispatch('piantina-selezionata-mappa', { mappaId: piantina.mappaId });
});
layerSalvate.addLayer(layer);
if (selezionata) {
layerSelezionata = layer;
}
}
// Aprire una piantina dall'elenco deve portarci sopra; un salvataggio
// no, altrimenti la vista salta via mentre si sta ancora lavorando.
if (adattaVista && layerSelezionata) {
mappa.fitBounds(layerSelezionata.getBounds().pad(0.25));
return;
}
if (!adattaVista) {
return;
}
const riferimento = layerArea ?? (dati.piantine.length ? layerSalvate : null);
if (riferimento) {
mappa.fitBounds(riferimento.getBounds().pad(0.08));
if (selezionata !== null && layers.has(selezionata)) {
inquadra(selezionata);
} else if (layerArea) {
mappa.fitBounds(layerArea.getBounds().pad(0.08));
} else if (layers.size) {
mappa.fitBounds(gruppo.getBounds().pad(0.08));
} else {
mappa.setView([41.9, 12.5], 6); // Italia: nessun dato ancora caricato
}
}
ridisegna(dati, true);
mappa.setView([41.9, 12.5], 6);
mappa.on(L.Draw.Event.CREATED, (evento) => {
if (layerInAttesa) {
mappa.removeLayer(layerInAttesa);
}
layerInAttesa = evento.layer;
layerInAttesa.setStyle(STILE_IN_ATTESA);
layerInAttesa.addTo(mappa);
window.Livewire.dispatch('poligono-disegnato', { geometry: evento.layer.toGeoJSON().geometry });
});
let registrato = false;
document.addEventListener('livewire:init', registraEventi);
if (window.Livewire) {
registraEventi();
const avvia = () => { registra(); carica(); };
if (window.Livewire?.find && radice && window.Livewire.find(radice.getAttribute('wire:id'))) {
avvia();
} else {
document.addEventListener('livewire:initialized', avvia, { once: true });
}
function registraEventi() {
if (registrato) {
return;
}
registrato = true;
window.Livewire.on('piantine-dati-aggiornati', ({ dati: nuovi, inquadra }) => {
// Dopo un salvataggio il poligono provvisorio è diventato
// definitivo (o è stato scartato): via il tracciato rosso.
if (layerInAttesa) {
mappa.removeLayer(layerInAttesa);
layerInAttesa = null;
}
ridisegna(nuovi, Boolean(inquadra));
});
}
// La mappa nasce dentro un contenitore che il pannello laterale può
// coprire: senza questo, Leaflet resta convinto delle misure di partenza e
// le tessere si disallineano dal puntatore.
window.addEventListener('resize', () => mappa.invalidateSize());
setTimeout(() => mappa.invalidateSize(), 60);
// Pannelli che si aprono e chiudono cambiano le misure del contenitore:
// senza, Leaflet resta convinto di quelle di partenza e le tessere si
// disallineano dal puntatore.
new ResizeObserver(() => mappa.invalidateSize()).observe(contenitore);
}
if (document.readyState === 'loading') {