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:
@@ -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: '© <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') {
|
||||
|
||||
Reference in New Issue
Block a user