Files
termanager2/resources/js/piantine.js
Francesco Picone 056d95cfcb 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>
2026-10-12 12:08:34 +02:00

337 lines
14 KiB
JavaScript

// Editor delle piantine (pagina /piantine/editor, solo amministratore).
//
// 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';
import 'leaflet-draw';
import 'leaflet-draw/dist/leaflet.draw.css';
const STILE_AREA = { color: '#4a45a8', weight: 2, dashArray: '8 6', fill: false, interactive: false };
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');
if (!contenitore || contenitore.dataset.inizializzato) {
return;
}
contenitore.dataset.inizializzato = '1';
const radice = contenitore.closest('[wire\\:id]');
const componente = () => window.Livewire.find(radice.getAttribute('wire:id'));
L.drawLocal.draw.handlers.polygon.tooltip = {
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;
let selezionata = null;
let modo = 'vista';
let gestoreDisegno = null;
let poligonoInModifica = null;
// ── Disegno delle piantine ──────────────────────────────────────────────
function stileDi(dati) {
if (dati.mappaId === selezionata) return STILE_SELEZIONATA;
return { dashArray: null, ...(STILI[dati.stato] ?? STILI.bozza) };
}
function disegna(dati) {
rimuovi(dati.mappaId, false);
if (!dati.geometry) return;
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();
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
}
}
mappa.setView([41.9, 12.5], 6);
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 });
}
// 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') {
document.addEventListener('DOMContentLoaded', initPiantine);
} else {
initPiantine();
}