// 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: '© OpenStreetMap 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(); }