Piantine staccate dai territori, editor a schermo intero, restyle
Le piantine non dipendono piu' da un territorio esistente: territorio_id e' nullable, la chiave esterna e' nullOnDelete invece di cascadeOnDelete, e `nome` fa da etichetta finche' il collegamento manca. Si disegna guardando le strade e si decide dopo di chi e' il disegno; se il territorio non c'e', lo si crea dall'editor con numero, zona, tipologia e confini — zona e tipologia comprese, create sul posto quando mancano. Il cambio di cascata chiude anche una perdita di dati silenziosa: ogni import XML svuota `territori` in blocco e finora si portava via tutte le piantine. Il disegno passa su /piantine/editor, a schermo intero con telaio proprio: su una mappa larga un terzo di schermo un confine si sbaglia, e finisce stampato. /piantine resta la gestione — area, generazione, collegamenti, storico. Cartolina S-12 reimpaginata: pagina 1 il modulo con la Localita' (che ora e' il nome della zona, non un campo duplicato) e i confini scritti nello spazio centrale, con il corpo del testo che si adatta alla lunghezza; pagina 2 la piantina sola al 98%. Aggiunta l'anteprima, che compone il PDF al volo senza toccare pdf_path ne' lo storico. Immagine: JPEG invece di PNG (0,9 MB contro 5 per territorio), composta alle tessere dello zoom piu' alto e ridotta con Lanczos, nelle proporzioni della pagina cosi' riempie il foglio invece di lasciare fasce bianche. Il tratto del confine e' espresso in pixel dell'immagine finale: si vede il confine e si legge la via che ci passa sotto. L'attribuzione OSM si dimensiona con l'immagine, come la ODbL richiede. Rimossa la rigenerazione massiva: job, stato in cache e interfaccia. Sistema visivo ripassato su tutta l'applicazione: le scale gray e indigo di Tailwind sono ridefinite nei token, cosi' le 37 viste si riallineano insieme invece di essere riscritte una per una. Grigi caldi, un accento solo con quattro significati, carattere servito dall'applicazione e non da Google Fonts, via il gradiente della testata. Aggiunto il link "salta al contenuto", che mancava. Corretto un guasto d'ambiente che bloccava TUTTI gli upload in sviluppo: dalla 1.26 il fastcgi_params di nginx passa HTTP_HOST come $host, che perde la porta, e ogni URL firmato smetteva di validare — l'endpoint di upload di Livewire rispondeva 401. 294 test verdi. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
160
HANDOFF.md
160
HANDOFF.md
@@ -4,7 +4,7 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
|
||||
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
|
||||
può dare: perché le cose sono come sono, e cosa resta aperto.
|
||||
|
||||
Ultimo aggiornamento: **31 luglio 2026**.
|
||||
Ultimo aggiornamento: **28 agosto 2026**.
|
||||
|
||||
---
|
||||
|
||||
@@ -22,7 +22,7 @@ redeploy, login, tutte le pagine, code e scheduler.
|
||||
| Sviluppo locale | ✅ stesso compose, profilo `dev` |
|
||||
| Backup / ripristino / azzeramento | ✅ verificati end-to-end in locale |
|
||||
| Ripristino da browser in linea | ⚠️ **limitato a 100 MB da Cloudflare** — vedi [Cosa resta aperto](#cosa-resta-aperto) |
|
||||
| Test automatici | ⚠️ **impalcatura pronta, 57 test** — copertura ancora parziale |
|
||||
| Test automatici | ⚠️ **impalcatura pronta, 294 test** — copertura ancora parziale |
|
||||
|
||||
**Punto in cui ci si è fermati (30/07/2026).** Su `develop` è pubblicato tutto:
|
||||
backup completo `.zip`, ripristino, azzeramento, cambio password forzato, barre
|
||||
@@ -39,6 +39,18 @@ architetturali](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi)
|
||||
Al primo deploy il compose costruisce anche l'immagine del renderer; non
|
||||
servono variabili nuove in Dokploy.
|
||||
|
||||
**Il 28/08 la sotto-app è stata rifatta e l'interfaccia ripassata.** Le piantine
|
||||
si sono staccate dai territori (una piantina vive anche scollegata, il territorio
|
||||
si crea dall'editor), il disegno è passato su una pagina a schermo intero, la
|
||||
cartolina S-12 è stata reimpaginata (confini sulla prima pagina, piantina al 98%
|
||||
sulla seconda) e la rigenerazione massiva è stata rimossa. In più il sistema
|
||||
visivo è stato ripassato su tutta l'applicazione, non solo qui: vedi [Il sistema
|
||||
visivo sta nei token](#il-sistema-visivo-sta-nei-token-non-nelle-viste).
|
||||
|
||||
Nello stesso giro è emerso e corretto un guasto d'ambiente che bloccava **tutti**
|
||||
gli upload in sviluppo: vedi [`HTTP_HOST` a nginx deve conservare la
|
||||
porta](#http_host-a-nginx-deve-conservare-la-porta).
|
||||
|
||||
Il 30/07 sono state fatte due cose.
|
||||
|
||||
**La suite di test, che prima non esisteva**: 79 test eseguibili con un comando,
|
||||
@@ -166,13 +178,13 @@ password `secret`.
|
||||
|
||||
### Le piantine sono una sotto-app: renderer esterno, agganci minimi
|
||||
|
||||
La pagina **Piantine** (`/piantine`, solo amministratore) genera le mappe dei
|
||||
territori: si carica il KML dell'area della città, si disegnano i poligoni su
|
||||
OpenStreetMap (Leaflet), e da ogni poligono si ottiene un'immagine ad alta
|
||||
definizione e — a scelta — il PDF sul modulo ufficiale S-12
|
||||
(`resources/pdf/S-12_I.pdf`, importato con FPDI) che sostituisce quello del
|
||||
Le **Piantine** (solo amministratore) generano le mappe dei territori. Si carica
|
||||
il KML dell'area della città, si disegnano i confini su OpenStreetMap (Leaflet)
|
||||
nell'editor a schermo intero, e da ogni disegno si ottiene un'immagine ad alta
|
||||
definizione e — a scelta — la cartolina sul modulo ufficiale S-12
|
||||
(`resources/pdf/S-12_I.pdf`, importato con FPDI) che sostituisce il PDF del
|
||||
territorio. Il PDF precedente finisce nella tabella `territorio_pdf_versioni`
|
||||
ed è ripristinabile dalla stessa pagina.
|
||||
ed è ripristinabile dalla pagina di gestione.
|
||||
|
||||
Il rendering vero e proprio **non sta nell'applicazione**: lo fa il container
|
||||
`map-renderer` (`docker/renderer/`, Node + sharp), che scarica le tessere OSM
|
||||
@@ -192,9 +204,104 @@ Le geometrie vivono nella tabella separata `territorio_mappe`, l'area KML in
|
||||
`storage/app/piantine/`: rimuovere la sotto-app significa togliere questi
|
||||
pezzi, non disfare il gestionale.
|
||||
|
||||
La rigenerazione massiva (`Jobs/RigeneraPiantine`) usa lo stesso schema di
|
||||
avanzamento di backup e import PDF (`PiantineOperationState`, cache + poll
|
||||
della pagina, un'operazione alla volta).
|
||||
**Il disegno viene prima del numero.** Una piantina non ha bisogno di un
|
||||
territorio: `territorio_mappe.territorio_id` è nullable e il collegamento si fa
|
||||
(e si disfa) quando si vuole, con `nome` come etichetta finché manca. Si taglia
|
||||
l'area guardando le strade e solo dopo si decide di chi è — e se quel territorio
|
||||
non esiste, lo si crea dall'editor col solo numero. La chiave esterna è
|
||||
`nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare
|
||||
`territori` come fa ogni import XML, non porta più via i disegni.
|
||||
|
||||
**Due pagine, non una.** `/piantine` tiene area, generazione, collegamenti e
|
||||
storico; `/piantine/editor` è l'editor a schermo intero, con un telaio suo
|
||||
(`layouts/editor`) senza barra laterale. Tagliare un confine su una mappa larga
|
||||
un terzo di schermo vuol dire sbagliarlo, e quel disegno finisce stampato.
|
||||
|
||||
La rigenerazione massiva è stata tolta: job, stato in cache e interfaccia. Si
|
||||
rigenera un territorio alla volta dall'elenco.
|
||||
|
||||
**La cartolina e la piantina sono due pagine distinte.** Il riquadro dell'S-12 è
|
||||
largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi
|
||||
pagina 1 è il modulo con Località, numero e i **confini scritti** nello spazio
|
||||
centrale (il corpo del testo si restringe da solo finché ci sta), pagina 2 è la
|
||||
piantina sola al 98% della pagina.
|
||||
|
||||
**La "Località" del modulo è il nome della zona.** Era un campo globale, poi un
|
||||
campo della piantina, e in entrambi i casi era un doppione: la zona esiste già,
|
||||
dice la stessa cosa («Palermo Montepellegrino») ed è quella che si aggiorna
|
||||
quando la congregazione riorganizza il lavoro. Due posti dove scrivere lo stesso
|
||||
nome divergono prima o poi, e sulla carta finisce quello sbagliato. L'elenco
|
||||
marca i territori collegati senza zona: senza, l'intestazione della cartolina
|
||||
esce vuota e ce se ne accorge in stampa.
|
||||
|
||||
**L'anteprima non tocca niente.** `piantine.anteprima` compone il PDF al volo e
|
||||
lo serve `inline`: non aggiorna `pdf_path`, non archivia versioni, non lascia
|
||||
file. «Genera PDF» manda il documento in vigore, e scoprire un confine sbagliato
|
||||
dopo significa trovarselo già nello storico.
|
||||
|
||||
**L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte
|
||||
che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`):
|
||||
|
||||
- Il renderer compone alle tessere dello zoom più alto che entri in
|
||||
`max_side × 2` e poi riduce con Lanczos. Le etichette OSM sono disegnate
|
||||
dentro la tessera a dimensione fissa: salire di uno zoom le rende più
|
||||
definite, e ridurre dopo porta quella nitidezza dentro l'immagine finale.
|
||||
- Chiede al renderer le **proporzioni della pagina** (`aspetto`): un territorio
|
||||
quasi quadrato su una cartolina orizzontale, tenendo le sue proporzioni,
|
||||
lascerebbe due fasce bianche larghe un quinto di foglio. Allargando il
|
||||
ritaglio si mostrano più dintorni invece di carta vuota.
|
||||
- JPEG a qualità 86 invece di PNG: 0,9 MB invece di 5 per territorio. Con 274
|
||||
territori è la differenza fra qualche centinaio di MB e diversi GB.
|
||||
|
||||
L'attribuzione OSM si dimensiona sull'immagine finale, non su una misura fissa:
|
||||
22 px su 3400 sarebbero meno di un millimetro stampato, cioè presenti nel file
|
||||
ma invisibili a chi tiene la cartolina — e la ODbL la vuole leggibile. Per lo
|
||||
stesso motivo il tratto del confine è espresso in pixel dell'immagine finale e
|
||||
riportato alla scala di composizione: deve dire dove passa il confine senza
|
||||
cancellare la via che ci corre sotto, che è proprio quella che serve leggere.
|
||||
|
||||
### Il sistema visivo sta nei token, non nelle viste
|
||||
|
||||
`resources/css/app.css` **ridefinisce le scale `gray` e `indigo` di Tailwind**
|
||||
dentro `@theme`. È la cosa che sorprende di più aprendo il file, ed è
|
||||
deliberata: le trentasette viste sono scritte con le utility (`text-gray-600`,
|
||||
`bg-indigo-50`), e cambiare la scala le riallinea tutte insieme. L'alternativa
|
||||
— riscrivere le classi vista per vista — a ogni file dimenticato lascia in giro
|
||||
il grigio di prima accanto a quello nuovo, e nessuno se ne accorge finché non
|
||||
apre proprio quella pagina.
|
||||
|
||||
Da qui tre conseguenze che vanno sapute prima di metterci mano:
|
||||
|
||||
- **I grigi sono caldi.** Quelli di serie di Tailwind tirano al blu e, sommati a
|
||||
un accento indaco, davano all'interfaccia una dominante fredda. Questi elenchi
|
||||
diventano moduli di carta che passano di mano: un fondo caldo li avvicina a
|
||||
quello che sono. Non mescolare grigi freddi qui dentro.
|
||||
- **L'accento è uno.** Indaco = azione, verde = fatto, ambra = in sospeso,
|
||||
rosso = distrugge dati. Se una cosa non rientra in queste quattro, è grigia.
|
||||
Prima le tinte erano cinque e decorative.
|
||||
- **Il carattere è servito dall'applicazione** (`@fontsource-variable/figtree`,
|
||||
dipendenza npm), non da Google Fonts. Un gestionale che tiene nomi e indirizzi
|
||||
di persone non deve chiamare un dominio di terze parti a ogni pagina caricata,
|
||||
e così funziona anche senza rete.
|
||||
|
||||
I componenti `tm-*` (pulsanti, console, card, campi, elenchi, editor) stanno
|
||||
tutti qui e **fuori da `@layer`**, quindi vincono sulle utility: un pulsante non
|
||||
si può ritoccare a metà con le classi. Chi aggiunge una schermata usa questi,
|
||||
non catene di utility nuove.
|
||||
|
||||
### `HTTP_HOST` a nginx deve conservare la porta
|
||||
|
||||
`docker/nginx/default.conf` rimette `fastcgi_param HTTP_HOST $http_host;` dopo
|
||||
l'`include fastcgi_params`. Non e' ridondanza: dalla 1.26 il `fastcgi_params` di
|
||||
sistema usa `$host`, che la porta la butta via. Con `APP_URL` su porta non
|
||||
standard — cioe' lo sviluppo locale su `:8090` — Laravel ricostruisce l'URL
|
||||
della richiesta senza porta mentre `URL::forceRootUrl()` genera i link con, e
|
||||
ogni URL firmato non valida piu': **tutti** gli upload di file rispondono 401,
|
||||
perche' l'endpoint di Livewire aborta sulla firma. In produzione `APP_URL` non
|
||||
ha porta e le due forme coincidono, quindi il guasto si vede solo in locale.
|
||||
Inoltrare l'header grezzo e' sicuro perche' `trustHosts()` in `bootstrap/app.php`
|
||||
rifiuta gia' gli Host che non siano quello di `APP_URL`, `localhost` o
|
||||
`127.0.0.1`.
|
||||
|
||||
### Modifica/cancellazione delle assegnazioni: solo amministratore
|
||||
|
||||
@@ -918,7 +1025,7 @@ blocchi che impedivano di scrivere il primo test sono stati rimossi: stage
|
||||
docker compose --profile test run --build --rm test
|
||||
```
|
||||
|
||||
**Coperto oggi — 233 test, tutti verdi:**
|
||||
**Coperto oggi — 294 test, tutti verdi:**
|
||||
|
||||
| File | Cosa protegge |
|
||||
|------|---------------|
|
||||
@@ -929,6 +1036,10 @@ docker compose --profile test run --build --rm test
|
||||
| `tests/Feature/PaginaBackupImportTest.php` | La pagina servita per intero: permessi, le cinque zone con la loro ancora **e nel loro ordine**, la console unica, l'assenza dei vecchi riquadri di log, che nessun tag `<x-btn` sia rimasto da compilare, che i pulsanti usino il sistema unico, e che la console sia definita prima del pacchetto che avvia Alpine |
|
||||
| `tests/Feature/PagineApplicativeTest.php` | **Ogni** pagina dell'applicazione si apre davvero (200) e non ha markup rimasto da compilare. È la rete che serve quando una modifica tocca tutte le viste in una volta: un errore di markup non rompe niente finché qualcuno non apre quella pagina |
|
||||
| `tests/Unit/StileUnificatoTest.php` | Che i vocabolari di stile paralleli non rientrino: `.btn-action*`, `.btn-primary-*`, l'hover simulato con `onmouseover`, gli sfondi scritti a mano sui pulsanti, le classi `tm-btn` messe in una vista invece che dal componente |
|
||||
| `tests/Feature/PiantineManagerTest.php` | La pagina di gestione: accesso riservato, KML accettato e rifiutato, collegamento e scollegamento di una piantina, rifiuto di un territorio che ne ha gia' una, cancellare il territorio **non** cancella il disegno (la regressione che la cascata causava a ogni import XML), niente PDF senza territorio, e l'anteprima che non tocca `pdf_path` ne' lo storico |
|
||||
| `tests/Feature/PiantineEditorTest.php` | L'editor: piantina salvata senza territorio, territorio creato dalla scheda con zona/tipologia/confini, aggiornamento di uno esistente, zona e tipologia create sul posto e lasciate selezionate, numero e zona duplicati respinti, ridisegno che sostituisce senza duplicare, geometria non valida ignorata |
|
||||
| `tests/Unit/PiantinaPdfServiceTest.php` | La cartolina: due pagine, la Localita' presa dalla zona e i confini davvero presenti nel PDF (estratti decomprimendo i flussi, non solo "non ha sollevato eccezioni"), il giro completo dello storico — sostituzione, ripristino reversibile, file sparito |
|
||||
| `tests/Unit/PiantinaRendererTest.php` | Il client del renderer: formato, qualita' e lato massimo davvero spediti, JPEG salvato con l'estensione giusta, il file di un formato precedente rimosso invece che lasciato orfano, errori e risposte non-immagine trasformati in messaggi leggibili, geometria corrotta bloccata prima della chiamata |
|
||||
| `tests/Feature/HomeMediaDurataTest.php` | La "media percorrenza congregazione": unità di misura, esclusione delle assegnazioni aperte, dei rientri nello stesso giorno, delle date impossibili ereditate dalla vecchia applicazione e degli altri anni di servizio |
|
||||
|
||||
Il primo giro ha già trovato tre difetti reali, tutti corretti: il warning di
|
||||
@@ -1007,9 +1118,12 @@ questi elementi si può mettere a fuoco — ma sono lo stesso genere di debito:
|
||||
stesso verde compare in quattro tonalità leggermente diverse, e cambiare la
|
||||
tinta dell'applicazione oggi significa cercare a mano tutte le occorrenze.
|
||||
|
||||
La via è la stessa già percorsa: dei token in `app.css` (o le variabili di tema
|
||||
di Tailwind 4) e le viste che li citano invece di ripetere i valori. Il test
|
||||
`StileUnificatoTest` è il posto dove aggiungere la guardia, quando si farà.
|
||||
**Dal 28/08 metà del lavoro è fatta**: i token esistono (`:root` e `@theme` in
|
||||
`app.css`, vedi *Il sistema visivo sta nei token*), e le scale `gray`/`indigo`
|
||||
di Tailwind sono ridefinite, quindi tutto ciò che usa le utility è già
|
||||
allineato. Resta da togliere gli `style=` con gli esadecimali scritti a mano e
|
||||
farli citare i token. Il test `StileUnificatoTest` è il posto dove aggiungere la
|
||||
guardia, quando si farà.
|
||||
|
||||
Fuori perimetro per scelta: `resources/views/pdf/*` le impagina dompdf, che di
|
||||
Tailwind e delle nostre classi non sa niente. `StileUnificatoTest` esclude anche
|
||||
@@ -1018,6 +1132,22 @@ pubblicate da Laravel sono state rimosse perche' i paginatori sono tutti dentro
|
||||
componenti Livewire, che risolvono la vista dal namespace `livewire::` e non da
|
||||
`pagination::`. L'esclusione resta a guardia di future viste di terze parti.
|
||||
|
||||
### 7. Il backup non contiene l'area KML delle piantine
|
||||
|
||||
`BackupArchive` impacchetta il solo disco `public` (`storage/app/public`), e
|
||||
`PiantineArea` scrive in `storage/app/piantine/` — una cartella **sorella**, non
|
||||
dentro. Verificato: dopo un ripristino su volumi vuoti l'area va ricaricata a
|
||||
mano dal KML originale, che quindi conviene conservare fuori di qui.
|
||||
|
||||
I disegni invece si salvano: stanno in `territorio_mappe`, e da quando la chiave
|
||||
esterna è `nullOnDelete` sopravvivono anche allo svuotamento di `territori` che
|
||||
fa ogni import XML — scollegati, ma recuperabili.
|
||||
|
||||
Sistemarlo vuol dire estendere `BackupArchive` a una seconda cartella, cosa che
|
||||
tocca anche il formato dell'archivio (`FORMAT_VERSION`) e il ripristino. Non è
|
||||
stato fatto perché il KML è un file che l'utente ha già sul suo computer: la
|
||||
perdita è un fastidio, non un dato irrecuperabile.
|
||||
|
||||
---
|
||||
|
||||
## Trappole da conoscere
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Services\Piantine\PiantinaPdfService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Anteprima della cartolina, senza sostituire niente.
|
||||
*
|
||||
* Il PDF viene composto al volo e servito da leggere: non tocca `pdf_path` del
|
||||
* territorio, non archivia versioni, non lascia file sul disco. Serve a
|
||||
* guardare come viene prima di decidere — perché "Genera PDF" è un'operazione
|
||||
* che manda il documento in vigore, e vederlo solo dopo significa scoprire un
|
||||
* confine sbagliato quando è già nello storico.
|
||||
*/
|
||||
class PiantinaAnteprimaController extends Controller
|
||||
{
|
||||
public function __invoke(TerritorioMappa $mappa, PiantinaPdfService $pdfService): Response
|
||||
{
|
||||
$mappa->load('territorio.zona');
|
||||
|
||||
abort_unless($mappa->collegata(), 404);
|
||||
|
||||
$pdf = $pdfService->genera($mappa->territorio, $mappa);
|
||||
|
||||
return response($pdf, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
// `inline`: si apre nel visualizzatore del browser invece di
|
||||
// finire fra i download, che di un'anteprima non se ne fa niente.
|
||||
'Content-Disposition' => 'inline; filename="anteprima-territorio-'
|
||||
. $mappa->territorio->numero . '.pdf"',
|
||||
// Un'anteprima rispecchia lo stato di adesso: se la cache la
|
||||
// congela, si continua a guardare il confine di prima.
|
||||
'Cache-Control' => 'no-store, max-age=0',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Services\Piantine\PiantinaPdfService;
|
||||
use App\Services\Piantine\PiantinaRenderer;
|
||||
use App\Services\Piantine\PiantineOperationState;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Rigenerazione massiva delle piantine: per ogni territorio con un poligono
|
||||
* disegnato rigenera l'immagine dal renderer e, a richiesta, ricompone e
|
||||
* sostituisce anche il PDF (versione precedente archiviata nello storico).
|
||||
*
|
||||
* Un territorio che fallisce non ferma gli altri: viene contato e loggato,
|
||||
* l'esito finale riepiloga quanti sono andati a buon fine.
|
||||
*/
|
||||
class RigeneraPiantine implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
// Rendering + PDF per decine di territori: largo ma non illimitato, per
|
||||
// non lasciare il worker inchiodato se il renderer smette di rispondere.
|
||||
public int $timeout = 5400;
|
||||
|
||||
public function __construct(
|
||||
public string $operationId,
|
||||
public bool $anchePdf,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(
|
||||
PiantineOperationState $state,
|
||||
PiantinaRenderer $renderer,
|
||||
PiantinaPdfService $pdfService,
|
||||
): void {
|
||||
$state->markRunning($this->operationId);
|
||||
$state->log($this->operationId, 'Rigenerazione avviata.');
|
||||
|
||||
$mappe = TerritorioMappa::with('territorio')->orderBy('territorio_id')->get();
|
||||
|
||||
$processed = 0;
|
||||
$falliti = 0;
|
||||
|
||||
foreach ($mappe as $mappa) {
|
||||
$territorio = $mappa->territorio;
|
||||
$etichetta = $territorio ? "territorio {$territorio->numero}" : "mappa #{$mappa->id}";
|
||||
|
||||
try {
|
||||
if (! $territorio) {
|
||||
throw new \RuntimeException('il territorio associato non esiste più');
|
||||
}
|
||||
|
||||
$renderer->render($mappa);
|
||||
$state->log($this->operationId, "Immagine rigenerata per {$etichetta}.");
|
||||
|
||||
if ($this->anchePdf) {
|
||||
$pdfService->sostituisci($territorio, $mappa, 'rigenerazione');
|
||||
$state->log($this->operationId, "PDF sostituito per {$etichetta}.");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$falliti++;
|
||||
$state->log($this->operationId, "[ERRORE] {$etichetta}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$processed++;
|
||||
$state->avanza($this->operationId, $processed, $falliti);
|
||||
}
|
||||
|
||||
$state->log(
|
||||
$this->operationId,
|
||||
'Rigenerazione conclusa: ' . ($processed - $falliti) . ' riuscite, ' . $falliti . ' fallite.'
|
||||
);
|
||||
$state->markCompleted($this->operationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chiude l'operazione quando il job muore fuori da handle() (timeout,
|
||||
* worker terminato): senza, la pagina resterebbe a interrogare per sempre
|
||||
* uno stato "running".
|
||||
*/
|
||||
public function failed(?\Throwable $e): void
|
||||
{
|
||||
app(PiantineOperationState::class)->markFailed(
|
||||
$this->operationId,
|
||||
$e?->getMessage() ?? 'Job interrotto in modo inatteso.'
|
||||
);
|
||||
}
|
||||
}
|
||||
401
app/Livewire/Piantine/PiantineEditor.php
Normal file
401
app/Livewire/Piantine/PiantineEditor.php
Normal file
@@ -0,0 +1,401 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Piantine;
|
||||
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\Tipologia;
|
||||
use App\Models\Zona;
|
||||
use App\Services\Piantine\PiantinaRenderer;
|
||||
use App\Services\Piantine\PiantineArea;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Editor delle piantine a schermo intero (solo amministratore).
|
||||
*
|
||||
* Una pagina sola con la mappa grande quanto la finestra e i comandi in una
|
||||
* barra sottile in alto: tagliare un confine su una cartografia larga un terzo
|
||||
* di schermo significa sbagliarlo, e questo disegno finisce stampato su un
|
||||
* modulo che poi gira di mano in mano.
|
||||
*
|
||||
* Il disegno viene prima del numero. Si taglia l'area guardando le strade, e
|
||||
* solo dopo si compila la scheda del territorio — che può anche non esistere
|
||||
* ancora: numero, zona, tipologia e confini si scrivono da qui, e se la zona o
|
||||
* la tipologia mancano si creano sul posto, senza abbandonare il disegno.
|
||||
*/
|
||||
#[Layout('components.layouts.editor')]
|
||||
class PiantineEditor extends Component
|
||||
{
|
||||
/** Piantina aperta, o null quando se ne sta disegnando una nuova. */
|
||||
public ?int $mappaId = null;
|
||||
|
||||
public string $nome = '';
|
||||
|
||||
/** Geometria appena disegnata, non ancora salvata. */
|
||||
public ?string $geometriaInAttesa = null;
|
||||
|
||||
public bool $pannelloAperto = false;
|
||||
|
||||
// ─── Scheda del territorio ──────────────────────────────────────────────
|
||||
|
||||
/** '' = nessun territorio, 'nuovo' = da creare, altrimenti l'id. */
|
||||
public string $territorioScelto = '';
|
||||
|
||||
public string $numero = '';
|
||||
|
||||
public string $zonaId = '';
|
||||
|
||||
public string $tipologiaId = '';
|
||||
|
||||
public string $confini = '';
|
||||
|
||||
/** Campi di creazione al volo, aperti solo quando servono. */
|
||||
public string $nuovaZona = '';
|
||||
|
||||
public string $nuovaTipologia = '';
|
||||
|
||||
public function mount(?int $mappa = null): void
|
||||
{
|
||||
if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) {
|
||||
$this->apri($mappa);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Selezione ──────────────────────────────────────────────────────────
|
||||
|
||||
#[On('piantina-selezionata-mappa')]
|
||||
public function apri($mappaId): void
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->find(is_numeric($mappaId) ? (int) $mappaId : null);
|
||||
|
||||
if (! $mappa) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mappaId = $mappa->id;
|
||||
$this->nome = (string) $mappa->nome;
|
||||
$this->geometriaInAttesa = null;
|
||||
$this->pannelloAperto = true;
|
||||
|
||||
$this->caricaScheda($mappa->territorio);
|
||||
$this->aggiornaMappaJs(inquadra: true);
|
||||
}
|
||||
|
||||
public function nuova(): void
|
||||
{
|
||||
$this->reset([
|
||||
'mappaId', 'nome', 'geometriaInAttesa',
|
||||
'territorioScelto', 'numero', 'zonaId', 'tipologiaId', 'confini',
|
||||
'nuovaZona', 'nuovaTipologia',
|
||||
]);
|
||||
$this->resetValidation();
|
||||
$this->pannelloAperto = true;
|
||||
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function chiudiPannello(): void
|
||||
{
|
||||
$this->pannelloAperto = false;
|
||||
}
|
||||
|
||||
/** Riempie i campi della scheda dal territorio collegato (o li svuota). */
|
||||
private function caricaScheda(?Territorio $territorio): void
|
||||
{
|
||||
$this->territorioScelto = $territorio ? (string) $territorio->id : '';
|
||||
$this->numero = (string) ($territorio->numero ?? '');
|
||||
$this->zonaId = (string) ($territorio->zona_id ?? '');
|
||||
$this->tipologiaId = (string) ($territorio->tipologia_id ?? '');
|
||||
$this->confini = (string) ($territorio->confini ?? '');
|
||||
}
|
||||
|
||||
/** Cambiare territorio nella tendina ricarica la sua scheda. */
|
||||
public function updatedTerritorioScelto(string $valore): void
|
||||
{
|
||||
$this->resetValidation();
|
||||
|
||||
if ($valore === 'nuovo') {
|
||||
$this->numero = '';
|
||||
$this->zonaId = '';
|
||||
$this->tipologiaId = '';
|
||||
$this->confini = '';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->caricaScheda($valore === '' ? null : Territorio::find($valore));
|
||||
}
|
||||
|
||||
// ─── Disegno ────────────────────────────────────────────────────────────
|
||||
|
||||
#[On('poligono-disegnato')]
|
||||
public function poligonoDisegnato($geometry): void
|
||||
{
|
||||
if (! is_array($geometry)
|
||||
|| ! in_array($geometry['type'] ?? null, ['Polygon', 'MultiPolygon'], true)
|
||||
|| ! is_array($geometry['coordinates'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->geometriaInAttesa = json_encode($geometry);
|
||||
$this->pannelloAperto = true;
|
||||
}
|
||||
|
||||
public function scartaDisegno(): void
|
||||
{
|
||||
$this->geometriaInAttesa = null;
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
// ─── Zona e tipologia create sul posto ──────────────────────────────────
|
||||
|
||||
public function creaZona()
|
||||
{
|
||||
$dati = $this->validate(
|
||||
['nuovaZona' => ['required', 'string', 'max:100', Rule::unique('zone', 'nome')]],
|
||||
[
|
||||
'nuovaZona.required' => 'Scrivi il nome della zona.',
|
||||
'nuovaZona.unique' => 'Questa zona esiste già: sceglila dall\'elenco.',
|
||||
],
|
||||
);
|
||||
|
||||
$zona = Zona::create(['nome' => trim($dati['nuovaZona']), 'attivo' => true]);
|
||||
|
||||
$this->zonaId = (string) $zona->id;
|
||||
$this->nuovaZona = '';
|
||||
|
||||
session()->flash('success', "Zona «{$zona->nome}» creata e selezionata.");
|
||||
}
|
||||
|
||||
public function creaTipologia()
|
||||
{
|
||||
$dati = $this->validate(
|
||||
['nuovaTipologia' => ['required', 'string', 'max:100', Rule::unique('tipologie', 'nome')]],
|
||||
[
|
||||
'nuovaTipologia.required' => 'Scrivi il nome della tipologia.',
|
||||
'nuovaTipologia.unique' => 'Questa tipologia esiste già: sceglila dall\'elenco.',
|
||||
],
|
||||
);
|
||||
|
||||
$tipologia = Tipologia::create(['nome' => trim($dati['nuovaTipologia']), 'attivo' => true]);
|
||||
|
||||
$this->tipologiaId = (string) $tipologia->id;
|
||||
$this->nuovaTipologia = '';
|
||||
|
||||
session()->flash('success', "Tipologia «{$tipologia->nome}» creata e selezionata.");
|
||||
}
|
||||
|
||||
// ─── Salvataggio ────────────────────────────────────────────────────────
|
||||
|
||||
public function salva()
|
||||
{
|
||||
$mappa = $this->mappaId ? TerritorioMappa::find($this->mappaId) : null;
|
||||
|
||||
if (! $mappa && ! $this->geometriaInAttesa) {
|
||||
session()->flash('error', 'Disegna il poligono prima di salvare.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$territorio = $this->salvaScheda($mappa);
|
||||
|
||||
// salvaScheda() torna false quando la validazione della scheda non
|
||||
// passa: in quel caso il messaggio è già a schermo e non si tocca nulla.
|
||||
if ($territorio === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$valori = [
|
||||
'territorio_id' => $territorio?->id,
|
||||
'nome' => trim($this->nome) !== '' ? trim($this->nome) : null,
|
||||
];
|
||||
|
||||
if ($this->geometriaInAttesa) {
|
||||
$valori['geojson'] = $this->geometriaInAttesa;
|
||||
}
|
||||
|
||||
if ($mappa) {
|
||||
$mappa->update($valori);
|
||||
} else {
|
||||
$mappa = TerritorioMappa::create($valori);
|
||||
$this->mappaId = $mappa->id;
|
||||
}
|
||||
|
||||
$this->geometriaInAttesa = null;
|
||||
|
||||
activity()->causedBy(auth()->user())->log('piantina_salvata');
|
||||
session()->flash('success', $mappa->fresh()->load('territorio')->etichetta() . ' salvata.');
|
||||
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea o aggiorna il territorio della scheda.
|
||||
*
|
||||
* @return Territorio|null|false Il territorio collegato, null se la
|
||||
* piantina resta scollegata, false se la
|
||||
* scheda non è valida.
|
||||
*/
|
||||
private function salvaScheda(?TerritorioMappa $mappa): Territorio|null|false
|
||||
{
|
||||
if ($this->territorioScelto === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$esistente = $this->territorioScelto === 'nuovo'
|
||||
? null
|
||||
: Territorio::find($this->territorioScelto);
|
||||
|
||||
if ($this->territorioScelto !== 'nuovo' && ! $esistente) {
|
||||
session()->flash('error', 'Il territorio scelto non esiste più.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->validate(
|
||||
[
|
||||
'numero' => [
|
||||
'required', 'string', 'max:20',
|
||||
Rule::unique('territori', 'numero')->ignore($esistente?->id),
|
||||
],
|
||||
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
|
||||
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
|
||||
'confini' => ['nullable', 'string', 'max:5000'],
|
||||
],
|
||||
[
|
||||
'numero.required' => 'Il territorio ha bisogno di un numero.',
|
||||
'numero.unique' => 'Esiste già un territorio con questo numero.',
|
||||
],
|
||||
['numero' => 'numero'],
|
||||
);
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
$this->pannelloAperto = true;
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Un territorio ha una piantina sola.
|
||||
if ($esistente) {
|
||||
$occupato = TerritorioMappa::where('territorio_id', $esistente->id)
|
||||
->when($mappa, fn ($q) => $q->where('id', '!=', $mappa->id))
|
||||
->exists();
|
||||
|
||||
if ($occupato) {
|
||||
session()->flash('error', "Il territorio {$esistente->numero} ha già una piantina: scollegala prima di assegnargli questa.");
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$valori = [
|
||||
'numero' => trim($this->numero),
|
||||
'zona_id' => $this->zonaId !== '' ? (int) $this->zonaId : null,
|
||||
'tipologia_id' => $this->tipologiaId !== '' ? (int) $this->tipologiaId : null,
|
||||
'confini' => trim($this->confini) !== '' ? trim($this->confini) : null,
|
||||
];
|
||||
|
||||
if ($esistente) {
|
||||
$esistente->update($valori);
|
||||
return $esistente;
|
||||
}
|
||||
|
||||
$territorio = Territorio::create($valori + ['attivo' => true]);
|
||||
$this->territorioScelto = (string) $territorio->id;
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('territorio_creato_da_piantine');
|
||||
|
||||
return $territorio;
|
||||
}
|
||||
|
||||
// ─── Immagine ed eliminazione ───────────────────────────────────────────
|
||||
|
||||
public function generaImmagine(PiantinaRenderer $renderer)
|
||||
{
|
||||
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
|
||||
|
||||
if (! $mappa) {
|
||||
session()->flash('error', 'Salva la piantina prima di generarne l\'immagine.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$renderer->render($mappa);
|
||||
} catch (\Throwable $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
|
||||
}
|
||||
|
||||
public function elimina()
|
||||
{
|
||||
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
|
||||
|
||||
if (! $mappa) {
|
||||
return;
|
||||
}
|
||||
|
||||
$etichetta = $mappa->etichetta();
|
||||
|
||||
if ($mappa->image_path) {
|
||||
Storage::disk('public')->delete($mappa->image_path);
|
||||
}
|
||||
$mappa->delete();
|
||||
|
||||
$this->nuova();
|
||||
session()->flash('success', "{$etichetta} eliminata.");
|
||||
}
|
||||
|
||||
// ─── Dati per la mappa ──────────────────────────────────────────────────
|
||||
|
||||
public function datiMappa(?PiantineArea $area = null): array
|
||||
{
|
||||
$area ??= app(PiantineArea::class);
|
||||
|
||||
$piantine = TerritorioMappa::with('territorio')->get()
|
||||
->filter(fn ($m) => $m->geometria() !== null)
|
||||
->map(fn ($m) => [
|
||||
'mappaId' => $m->id,
|
||||
'etichetta' => $m->etichetta(),
|
||||
'collegata' => $m->collegata(),
|
||||
'geometry' => $m->geometria(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'area' => $area->area(),
|
||||
'piantine' => $piantine,
|
||||
'selezionata' => $this->mappaId,
|
||||
];
|
||||
}
|
||||
|
||||
private function aggiornaMappaJs(bool $inquadra = false): void
|
||||
{
|
||||
$this->dispatch('piantine-dati-aggiornati', dati: $this->datiMappa(), inquadra: $inquadra);
|
||||
}
|
||||
|
||||
public function render(PiantineArea $area)
|
||||
{
|
||||
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
|
||||
|
||||
return view('livewire.piantine.piantine-editor', [
|
||||
'mappa' => $mappa,
|
||||
'areaCaricata' => $area->area() !== null,
|
||||
'datiMappaIniziali' => $this->datiMappa($area),
|
||||
'territori' => Territorio::attivi()
|
||||
->orderByRaw('LENGTH(numero), numero')
|
||||
->get(['id', 'numero']),
|
||||
'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
|
||||
'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']),
|
||||
'title' => 'Editor delle piantine',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,27 +2,23 @@
|
||||
|
||||
namespace App\Livewire\Piantine;
|
||||
|
||||
use App\Jobs\RigeneraPiantine;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use App\Services\Piantine\PiantinaPdfService;
|
||||
use App\Services\Piantine\PiantinaRenderer;
|
||||
use App\Services\Piantine\PiantineArea;
|
||||
use App\Services\Piantine\PiantineOperationState;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
/**
|
||||
* Sotto-app "Piantine" (solo amministratore).
|
||||
* Pagina di gestione delle piantine (solo amministratore).
|
||||
*
|
||||
* Dal KML dell'area città si disegnano i poligoni dei territori su una mappa
|
||||
* OpenStreetMap; da ogni poligono si genera l'immagine ad alta definizione
|
||||
* (container map-renderer) e, a scelta, il PDF sul modulo S-12 che sostituisce
|
||||
* quello del territorio, con storico versioni.
|
||||
* Qui si tiene l'area della città, si generano immagini e PDF, si collegano
|
||||
* le piantine ai territori e si ripescano le versioni sostituite. Il disegno
|
||||
* non sta qui: ha una pagina sua a schermo intero (PiantineEditor), perché su
|
||||
* una mappa larga un terzo di schermo non si taglia un confine.
|
||||
*/
|
||||
class PiantineManager extends Component
|
||||
{
|
||||
@@ -30,21 +26,10 @@ class PiantineManager extends Component
|
||||
|
||||
public $kmlFile = null;
|
||||
|
||||
public string $localita = '';
|
||||
/** Piantina di cui è aperto il pannello "collega a un territorio". */
|
||||
public ?int $collegamentoAperto = null;
|
||||
|
||||
public ?int $territorioSelezionato = null;
|
||||
|
||||
/** Geometria (JSON) appena disegnata sulla mappa, in attesa di salvataggio. */
|
||||
public ?string $geometriaInAttesa = null;
|
||||
|
||||
/** Id dell'operazione di rigenerazione massiva seguita dalla pagina. */
|
||||
public ?string $operazioneId = null;
|
||||
|
||||
public function mount(PiantineArea $area, PiantineOperationState $state): void
|
||||
{
|
||||
$this->localita = $area->localita();
|
||||
$this->operazioneId = $state->corrente();
|
||||
}
|
||||
public ?int $territorioDaCollegare = null;
|
||||
|
||||
// ─── Area città (KML) ───────────────────────────────────────────────────
|
||||
|
||||
@@ -77,91 +62,7 @@ class PiantineManager extends Component
|
||||
$this->kmlFile = null;
|
||||
|
||||
activity()->causedBy(auth()->user())->log('piantine_kml_caricato');
|
||||
session()->flash('success', "Area della città aggiornata ({$quante} geometrie riconosciute nel KML).");
|
||||
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function salvaLocalita(PiantineArea $area): void
|
||||
{
|
||||
$area->impostaLocalita($this->localita);
|
||||
session()->flash('success', 'Località aggiornata: comparirà sull\'intestazione dei prossimi PDF.');
|
||||
}
|
||||
|
||||
// ─── Editor dei poligoni ────────────────────────────────────────────────
|
||||
|
||||
#[On('poligono-disegnato')]
|
||||
public function poligonoDisegnato($geometry): void
|
||||
{
|
||||
if (! is_array($geometry)
|
||||
|| ! in_array($geometry['type'] ?? null, ['Polygon', 'MultiPolygon'], true)
|
||||
|| ! is_array($geometry['coordinates'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->geometriaInAttesa = json_encode($geometry);
|
||||
}
|
||||
|
||||
#[On('territorio-selezionato-mappa')]
|
||||
public function territorioSelezionatoDaMappa($territorioId): void
|
||||
{
|
||||
$this->territorioSelezionato = is_numeric($territorioId) ? (int) $territorioId : null;
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function updatedTerritorioSelezionato(): void
|
||||
{
|
||||
$this->territorioSelezionato = $this->territorioSelezionato ?: null;
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function salvaPoligono()
|
||||
{
|
||||
if (! $this->territorioSelezionato) {
|
||||
session()->flash('error', 'Scegli prima a quale territorio associare il poligono.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->geometriaInAttesa) {
|
||||
session()->flash('error', 'Disegna prima il poligono sulla mappa.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$territorio = Territorio::findOrFail($this->territorioSelezionato);
|
||||
|
||||
TerritorioMappa::updateOrCreate(
|
||||
['territorio_id' => $territorio->id],
|
||||
['geojson' => $this->geometriaInAttesa],
|
||||
);
|
||||
|
||||
$this->geometriaInAttesa = null;
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('piantina_poligono_salvato');
|
||||
session()->flash('success', "Poligono salvato per il territorio {$territorio->numero}. Ora puoi generare l'immagine.");
|
||||
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function scartaPoligono(): void
|
||||
{
|
||||
$this->geometriaInAttesa = null;
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function eliminaPoligono(int $mappaId)
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
$numero = $mappa->territorio?->numero ?? '?';
|
||||
|
||||
if ($mappa->image_path) {
|
||||
Storage::disk('public')->delete($mappa->image_path);
|
||||
}
|
||||
$mappa->delete();
|
||||
|
||||
session()->flash('success', "Poligono e immagine del territorio {$numero} eliminati (il PDF resta com'è).");
|
||||
$this->aggiornaMappaJs();
|
||||
session()->flash('success', "Area della città aggiornata: {$quante} geometrie riconosciute nel KML.");
|
||||
}
|
||||
|
||||
// ─── Generazione ────────────────────────────────────────────────────────
|
||||
@@ -178,55 +79,102 @@ class PiantineManager extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', "Immagine generata per il territorio {$mappa->territorio?->numero}.");
|
||||
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
|
||||
}
|
||||
|
||||
public function generaPdf(int $mappaId, PiantinaPdfService $pdfService)
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
$territorio = $mappa->territorio;
|
||||
|
||||
if (! $territorio) {
|
||||
session()->flash('error', 'Il territorio associato non esiste più.');
|
||||
if (! $mappa->collegata()) {
|
||||
session()->flash('error', 'Questa piantina non è collegata a nessun territorio: collegala prima di generare il PDF.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdfService->sostituisci($territorio, $mappa);
|
||||
$pdfService->sostituisci($mappa->territorio, $mappa);
|
||||
} catch (\Throwable $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', "PDF del territorio {$territorio->numero} sostituito. Il precedente è nello storico versioni.");
|
||||
session()->flash('success', "PDF del territorio {$mappa->territorio->numero} sostituito. Il precedente è nello storico versioni.");
|
||||
}
|
||||
|
||||
// ─── Rigenerazione massiva ──────────────────────────────────────────────
|
||||
// ─── Collegamento al territorio ─────────────────────────────────────────
|
||||
|
||||
public function rigeneraTutto(bool $anchePdf, PiantineOperationState $state)
|
||||
public function apriCollegamento(int $mappaId): void
|
||||
{
|
||||
if ($state->corrente() !== null) {
|
||||
session()->flash('error', 'C\'è già una rigenerazione in corso: attendi che finisca.');
|
||||
$this->collegamentoAperto = $mappaId;
|
||||
$this->territorioDaCollegare = TerritorioMappa::find($mappaId)?->territorio_id;
|
||||
}
|
||||
|
||||
public function chiudiCollegamento(): void
|
||||
{
|
||||
$this->collegamentoAperto = null;
|
||||
$this->territorioDaCollegare = null;
|
||||
}
|
||||
|
||||
public function collega(int $mappaId)
|
||||
{
|
||||
$mappa = TerritorioMappa::findOrFail($mappaId);
|
||||
|
||||
if (! $this->territorioDaCollegare) {
|
||||
session()->flash('error', 'Scegli il territorio a cui collegare la piantina.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$totale = TerritorioMappa::count();
|
||||
if ($totale === 0) {
|
||||
session()->flash('error', 'Nessun territorio ha ancora un poligono disegnato.');
|
||||
$territorio = Territorio::findOrFail($this->territorioDaCollegare);
|
||||
|
||||
// Un territorio ha una piantina sola: se ne aveva già un'altra il
|
||||
// vincolo di unicità la respingerebbe con un errore di database, che
|
||||
// non spiega niente a chi sta guardando la pagina.
|
||||
$occupato = TerritorioMappa::where('territorio_id', $territorio->id)
|
||||
->where('id', '!=', $mappa->id)
|
||||
->exists();
|
||||
|
||||
if ($occupato) {
|
||||
session()->flash('error', "Il territorio {$territorio->numero} ha già una piantina: scollegala prima di assegnargli questa.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id = (string) Str::uuid();
|
||||
$state->initialize($id, $totale, $anchePdf);
|
||||
RigeneraPiantine::dispatch($id, $anchePdf);
|
||||
$mappa->update(['territorio_id' => $territorio->id]);
|
||||
$this->chiudiCollegamento();
|
||||
|
||||
$this->operazioneId = $id;
|
||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('piantina_collegata');
|
||||
session()->flash('success', "Piantina collegata al territorio {$territorio->numero}.");
|
||||
}
|
||||
|
||||
activity()->causedBy(auth()->user())->log($anchePdf ? 'piantine_rigenerazione_pdf' : 'piantine_rigenerazione');
|
||||
public function scollega(int $mappaId)
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
$numero = $mappa->territorio?->numero ?? '?';
|
||||
|
||||
// Senza territorio serve un'etichetta, altrimenti la piantina sparisce
|
||||
// dall'elenco sotto un anonimo "Piantina #12".
|
||||
$mappa->update([
|
||||
'territorio_id' => null,
|
||||
'nome' => $mappa->nome ?: "Ex territorio {$numero}",
|
||||
]);
|
||||
|
||||
session()->flash('success', "Piantina scollegata dal territorio {$numero}. Il PDF del territorio resta com'è.");
|
||||
}
|
||||
|
||||
public function eliminaPiantina(int $mappaId)
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
$etichetta = $mappa->etichetta();
|
||||
|
||||
if ($mappa->image_path) {
|
||||
Storage::disk('public')->delete($mappa->image_path);
|
||||
}
|
||||
$mappa->delete();
|
||||
|
||||
session()->flash('success', "{$etichetta}: poligono e immagine eliminati. Il PDF del territorio resta com'è.");
|
||||
}
|
||||
|
||||
// ─── Storico versioni ───────────────────────────────────────────────────
|
||||
@@ -259,58 +207,26 @@ class PiantineManager extends Component
|
||||
session()->flash('success', 'Versione eliminata definitivamente.');
|
||||
}
|
||||
|
||||
// ─── Dati per la mappa e rendering ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Il payload che il JavaScript della mappa usa per disegnare: area della
|
||||
* città, poligoni salvati, selezione corrente.
|
||||
*/
|
||||
public function datiMappa(?PiantineArea $area = null): array
|
||||
public function render(PiantineArea $area)
|
||||
{
|
||||
$area ??= app(PiantineArea::class);
|
||||
|
||||
$poligoni = TerritorioMappa::with('territorio')->get()
|
||||
->filter(fn ($mappa) => $mappa->territorio !== null && $mappa->geometria() !== null)
|
||||
->map(fn ($mappa) => [
|
||||
'territorioId' => $mappa->territorio_id,
|
||||
'numero' => $mappa->territorio->numero,
|
||||
'geometry' => $mappa->geometria(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'area' => $area->area(),
|
||||
'poligoni' => $poligoni,
|
||||
'selezionato' => $this->territorioSelezionato,
|
||||
];
|
||||
}
|
||||
|
||||
private function aggiornaMappaJs(): void
|
||||
{
|
||||
$this->dispatch('piantine-dati-aggiornati', dati: $this->datiMappa());
|
||||
}
|
||||
|
||||
public function render(PiantineArea $area, PiantineOperationState $state)
|
||||
{
|
||||
$mappe = TerritorioMappa::with('territorio')->get()->keyBy('territorio_id');
|
||||
|
||||
$territori = Territorio::attivi()
|
||||
->orderByRaw('LENGTH(numero), numero')
|
||||
->get(['id', 'numero', 'pdf_path']);
|
||||
|
||||
$stato = $this->operazioneId ? $state->get($this->operazioneId) : null;
|
||||
|
||||
return view('livewire.piantine.piantine-manager', [
|
||||
'config' => $area->config(),
|
||||
'datiMappaIniziali' => $this->datiMappa($area),
|
||||
'territori' => $territori,
|
||||
'mappe' => $mappe,
|
||||
'piantine' => TerritorioMappa::with('territorio')
|
||||
->get()
|
||||
->sortBy(fn ($m) => [
|
||||
$m->collegata() ? 0 : 1,
|
||||
strlen((string) $m->territorio?->numero),
|
||||
(string) $m->territorio?->numero,
|
||||
(string) $m->nome,
|
||||
])
|
||||
->values(),
|
||||
'territori' => Territorio::attivi()
|
||||
->orderByRaw('LENGTH(numero), numero')
|
||||
->get(['id', 'numero']),
|
||||
'versioni' => TerritorioPdfVersione::with('territorio')
|
||||
->orderByDesc('sostituito_il')
|
||||
->limit(30)
|
||||
->get(),
|
||||
'stato' => $stato,
|
||||
'title' => 'Piantine dei territori',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,21 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Il disegno di un'area sulla mappa.
|
||||
*
|
||||
* Puo' essere collegato a un territorio oppure no: si disegna guardando la
|
||||
* cartografia, il numero si decide dopo. Finche' resta scollegato vale il
|
||||
* campo `nome` come etichetta; una volta collegato comanda il numero del
|
||||
* territorio, che e' il riferimento con cui lo chiamano tutti.
|
||||
*/
|
||||
class TerritorioMappa extends Model
|
||||
{
|
||||
protected $table = 'territorio_mappe';
|
||||
|
||||
protected $fillable = [
|
||||
'territorio_id',
|
||||
'nome',
|
||||
'geojson',
|
||||
'image_path',
|
||||
'rendered_at',
|
||||
@@ -37,4 +46,26 @@ class TerritorioMappa extends Model
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/** Se è collegata a un territorio, il PDF si può generare. */
|
||||
public function collegata(): bool
|
||||
{
|
||||
return $this->territorio_id !== null && $this->territorio !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Come si chiama questa piantina nell'interfaccia: il numero del
|
||||
* territorio se c'è, altrimenti il nome scelto a mano, altrimenti un
|
||||
* ripiego che almeno la distingue dalle altre.
|
||||
*/
|
||||
public function etichetta(): string
|
||||
{
|
||||
if ($this->collegata()) {
|
||||
return 'Terr. ' . $this->territorio->numero;
|
||||
}
|
||||
|
||||
$nome = trim((string) $this->nome);
|
||||
|
||||
return $nome !== '' ? $nome : 'Piantina #' . $this->id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,17 +11,27 @@ use RuntimeException;
|
||||
use setasign\Fpdi\Fpdi;
|
||||
|
||||
/**
|
||||
* Compone il PDF del territorio sul modulo ufficiale S-12 e lo sostituisce
|
||||
* nel gestionale conservando la versione precedente.
|
||||
* Compone il PDF del territorio e lo sostituisce nel gestionale conservando
|
||||
* la versione precedente.
|
||||
*
|
||||
* Pagina 1: il modulo S-12 originale (resources/pdf/S-12_I.pdf) importato
|
||||
* com'è, con Località, numero e piantina sovrapposti negli spazi previsti.
|
||||
* Pagina 2: i confini scritti (campo `confini` del territorio).
|
||||
* Pagina 1 — la cartolina: il modulo ufficiale S-12 (resources/pdf/S-12_I.pdf,
|
||||
* 146 x 94,4 mm) importato com'è, con Località e numero sulle righe
|
||||
* punteggiate e i confini scritti nello spazio centrale.
|
||||
*
|
||||
* La "Località" del modulo è il nome della zona del territorio: è già lì, dice
|
||||
* la stessa cosa, e si aggiorna in un posto solo quando la congregazione
|
||||
* riorganizza il lavoro.
|
||||
*
|
||||
* Pagina 2 — la piantina, sola, al 98% della pagina. Il riquadro dell'S-12 è
|
||||
* grande sette centimetri: una mappa di quartiere lì dentro non si legge, e
|
||||
* chi percorre il territorio guarda quella, non il modulo.
|
||||
*/
|
||||
class PiantinaPdfService
|
||||
{
|
||||
/** Quanto della pagina occupa la piantina. */
|
||||
private const OCCUPAZIONE = 0.98;
|
||||
|
||||
public function __construct(
|
||||
private PiantineArea $area,
|
||||
private TerritorioThumbnailService $thumbnails,
|
||||
) {
|
||||
}
|
||||
@@ -41,6 +51,7 @@ class PiantinaPdfService
|
||||
[$imgPxW, $imgPxH] = $dimensioni;
|
||||
|
||||
$pdf = new Fpdi();
|
||||
$pdf->SetAutoPageBreak(false);
|
||||
$pdf->setSourceFile(resource_path('pdf/S-12_I.pdf'));
|
||||
$template = $pdf->importPage(1);
|
||||
$misure = $pdf->getTemplateSize($template);
|
||||
@@ -48,60 +59,120 @@ class PiantinaPdfService
|
||||
$w = $misure['width'];
|
||||
$h = $misure['height'];
|
||||
|
||||
// ── Pagina 1: il modulo ────────────────────────────────────────────
|
||||
$pdf->AddPage($misure['orientation'], [$w, $h]);
|
||||
$pdf->useTemplate($template);
|
||||
$pdf->SetTextColor(0, 0, 0);
|
||||
|
||||
// Intestazione: i valori si appoggiano sulle righe punteggiate del
|
||||
// modulo. Posizioni in proporzione alla pagina, tarate sull'S-12.
|
||||
$pdf->SetFont('Helvetica', 'B', 10);
|
||||
$pdf->SetTextColor(0, 0, 0);
|
||||
|
||||
// Tarato sull'S-12 reale: i valori si appoggiano alle righe
|
||||
// punteggiate senza coprire le etichette "Località" e "Terr. N.".
|
||||
$headerY = $h * 0.112;
|
||||
$pdf->SetXY($w * 0.155, $headerY);
|
||||
$pdf->Cell($w * 0.48, 5, $this->latin($this->area->localita()), 0, 0, 'L');
|
||||
$headerY = $h * 0.125;
|
||||
$pdf->SetXY($w * 0.185, $headerY);
|
||||
$pdf->Cell($w * 0.45, 5, $this->latin((string) $territorio->zona?->nome), 0, 0, 'L');
|
||||
|
||||
$pdf->SetXY($w * 0.875, $headerY);
|
||||
$pdf->Cell($w * 0.10, 5, $this->latin($territorio->numero), 0, 0, 'C');
|
||||
|
||||
// Piantina nello spazio centrale, adattata mantenendo le proporzioni:
|
||||
// sotto l'intestazione, sopra la dicitura "(Incollate qui sopra...)".
|
||||
$boxX = $w * 0.045;
|
||||
$boxY = $h * 0.175;
|
||||
$boxW = $w * 0.91;
|
||||
$boxH = $h * 0.565;
|
||||
$this->scriviConfini($pdf, $territorio, $w, $h);
|
||||
|
||||
// ── Pagina 2: la piantina ──────────────────────────────────────────
|
||||
$pdf->AddPage($misure['orientation'], [$w, $h]);
|
||||
|
||||
$boxW = $w * self::OCCUPAZIONE;
|
||||
$boxH = $h * self::OCCUPAZIONE;
|
||||
|
||||
$scala = min($boxW / $imgPxW, $boxH / $imgPxH);
|
||||
$imgW = $imgPxW * $scala;
|
||||
$imgH = $imgPxH * $scala;
|
||||
|
||||
$pdf->Image(
|
||||
$immagine,
|
||||
$boxX + ($boxW - $imgW) / 2,
|
||||
$boxY + ($boxH - $imgH) / 2,
|
||||
($w - $imgW) / 2,
|
||||
($h - $imgH) / 2,
|
||||
$imgW,
|
||||
$imgH,
|
||||
'PNG'
|
||||
);
|
||||
|
||||
// Pagina 2: i confini scritti.
|
||||
$pdf->AddPage($misure['orientation'], [$w, $h]);
|
||||
$pdf->SetFont('Helvetica', 'B', 13);
|
||||
$pdf->SetXY($w * 0.045, $h * 0.06);
|
||||
$pdf->Cell($w * 0.91, 7, $this->latin('Confini del territorio N. ' . $territorio->numero), 0, 2, 'C');
|
||||
|
||||
$pdf->SetFont('Helvetica', '', 11);
|
||||
$pdf->SetXY($w * 0.045, $h * 0.16);
|
||||
$confini = trim((string) $territorio->confini);
|
||||
$pdf->MultiCell(
|
||||
$w * 0.91,
|
||||
6,
|
||||
$this->latin($confini !== '' ? $confini : 'Confini non ancora descritti.')
|
||||
$this->tipoImmagine($mappa->image_path)
|
||||
);
|
||||
|
||||
return $pdf->Output('S');
|
||||
}
|
||||
|
||||
/**
|
||||
* I confini nello spazio che sul modulo è riservato alla piantina.
|
||||
*
|
||||
* Il corpo del testo si restringe finché non ci sta: i confini di un
|
||||
* territorio vanno da due righe a venti, e una misura fissa o lascerebbe
|
||||
* mezzo modulo vuoto o sborderebbe sulla dicitura stampata sotto.
|
||||
*/
|
||||
private function scriviConfini(Fpdi $pdf, Territorio $territorio, float $w, float $h): void
|
||||
{
|
||||
$testo = trim((string) $territorio->confini);
|
||||
|
||||
if ($testo === '') {
|
||||
$pdf->SetFont('Helvetica', 'I', 9);
|
||||
$pdf->SetTextColor(120, 120, 120);
|
||||
$pdf->SetXY($w * 0.06, $h * 0.42);
|
||||
$pdf->Cell($w * 0.88, 5, $this->latin('Confini non ancora descritti.'), 0, 0, 'C');
|
||||
$pdf->SetTextColor(0, 0, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$boxX = $w * 0.06;
|
||||
$boxY = $h * 0.20;
|
||||
$boxW = $w * 0.88;
|
||||
$boxH = $h * 0.53;
|
||||
|
||||
$righe = preg_split('/\R/', $testo) ?: [$testo];
|
||||
|
||||
foreach ([10, 9, 8, 7, 6, 5.5, 5] as $corpo) {
|
||||
$interlinea = $corpo * 0.44;
|
||||
$pdf->SetFont('Helvetica', '', $corpo);
|
||||
|
||||
if ($this->altezzaTesto($pdf, $righe, $boxW, $interlinea) <= $boxH) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$pdf->SetXY($boxX, $boxY);
|
||||
foreach ($righe as $riga) {
|
||||
$riga = trim($riga);
|
||||
if ($riga === '') {
|
||||
$pdf->Ln($interlinea * 0.6);
|
||||
continue;
|
||||
}
|
||||
$pdf->SetX($boxX);
|
||||
$pdf->MultiCell($boxW, $interlinea, $this->latin($riga), 0, 'L');
|
||||
}
|
||||
}
|
||||
|
||||
/** Quanto verrebbe alto il blocco dei confini a questo corpo. */
|
||||
private function altezzaTesto(Fpdi $pdf, array $righe, float $larghezza, float $interlinea): float
|
||||
{
|
||||
$altezza = 0.0;
|
||||
|
||||
foreach ($righe as $riga) {
|
||||
$riga = trim($riga);
|
||||
if ($riga === '') {
|
||||
$altezza += $interlinea * 0.6;
|
||||
continue;
|
||||
}
|
||||
// GetStringWidth misura una riga sola: quante ne servono lo dice il
|
||||
// rapporto con la larghezza disponibile, arrotondato per eccesso.
|
||||
$larghezzaTesto = $pdf->GetStringWidth($this->latin($riga));
|
||||
$altezza += max(1, ceil($larghezzaTesto / $larghezza)) * $interlinea;
|
||||
}
|
||||
|
||||
return $altezza;
|
||||
}
|
||||
|
||||
/** FPDF vuole sapere il formato quando l'estensione non basta. */
|
||||
private function tipoImmagine(string $path): string
|
||||
{
|
||||
return strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'png' ? 'PNG' : 'JPEG';
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera il PDF e lo rende quello ufficiale del territorio, archiviando
|
||||
* l'eventuale PDF precedente nello storico versioni.
|
||||
|
||||
@@ -8,9 +8,9 @@ use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Client del container `map-renderer`: gli passa la geometria di un
|
||||
* territorio, riceve il PNG ad alta definizione e lo archivia sul disco
|
||||
* pubblico accanto a PDF e miniature.
|
||||
* Client del container `map-renderer`: gli passa la geometria di una piantina,
|
||||
* riceve l'immagine ad alta definizione e la archivia sul disco pubblico
|
||||
* accanto a PDF e miniature.
|
||||
*/
|
||||
class PiantinaRenderer
|
||||
{
|
||||
@@ -19,14 +19,21 @@ class PiantinaRenderer
|
||||
$geometry = $mappa->geometria();
|
||||
|
||||
if ($geometry === null) {
|
||||
throw new RuntimeException('La geometria salvata per questo territorio non è leggibile: ridisegna il poligono.');
|
||||
throw new RuntimeException('La geometria salvata per questa piantina non è leggibile: ridisegna il poligono.');
|
||||
}
|
||||
|
||||
$formato = config('piantine.formato') === 'png' ? 'png' : 'jpeg';
|
||||
|
||||
$response = Http::timeout((int) config('piantine.renderer_timeout'))
|
||||
->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [
|
||||
'geometry' => $geometry,
|
||||
'marginRatio' => (float) config('piantine.margin_ratio'),
|
||||
'maxSide' => (int) config('piantine.max_side'),
|
||||
'aspect' => (float) config('piantine.aspetto'),
|
||||
'strokeWidth' => (float) config('piantine.tratto'),
|
||||
'strokeOpacity' => (float) config('piantine.tratto_opacita'),
|
||||
'format' => $formato,
|
||||
'quality' => (int) config('piantine.qualita'),
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
@@ -34,13 +41,21 @@ class PiantinaRenderer
|
||||
throw new RuntimeException('Il renderer delle mappe ha risposto con un errore: ' . $dettaglio);
|
||||
}
|
||||
|
||||
$png = $response->body();
|
||||
if ($png === '' || ! str_starts_with($png, "\x89PNG")) {
|
||||
throw new RuntimeException('Il renderer delle mappe non ha restituito un PNG valido.');
|
||||
$immagine = $response->body();
|
||||
|
||||
if (! $this->immagineValida($immagine, $formato)) {
|
||||
throw new RuntimeException('Il renderer delle mappe non ha restituito un\'immagine valida.');
|
||||
}
|
||||
|
||||
$path = 'territori-mappe/territorio-' . $mappa->territorio_id . '.png';
|
||||
Storage::disk('public')->put($path, $png);
|
||||
// Il vecchio file puo' avere un'estensione diversa da quella nuova (si
|
||||
// e' passati da PNG a JPEG): senza questa rimozione resterebbe sul
|
||||
// volume per sempre, senza che niente lo referenzi piu'.
|
||||
if ($mappa->image_path && $mappa->image_path !== $this->percorso($mappa, $formato)) {
|
||||
Storage::disk('public')->delete($mappa->image_path);
|
||||
}
|
||||
|
||||
$path = $this->percorso($mappa, $formato);
|
||||
Storage::disk('public')->put($path, $immagine);
|
||||
|
||||
$mappa->update([
|
||||
'image_path' => $path,
|
||||
@@ -49,4 +64,21 @@ class PiantinaRenderer
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function percorso(TerritorioMappa $mappa, string $formato): string
|
||||
{
|
||||
return 'territori-mappe/piantina-' . $mappa->id . '.' . ($formato === 'png' ? 'png' : 'jpg');
|
||||
}
|
||||
|
||||
/** I primi byte bastano a distinguere un'immagine da una pagina d'errore. */
|
||||
private function immagineValida(string $dati, string $formato): bool
|
||||
{
|
||||
if ($dati === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $formato === 'png'
|
||||
? str_starts_with($dati, "\x89PNG")
|
||||
: str_starts_with($dati, "\xFF\xD8\xFF");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,12 @@ use RuntimeException;
|
||||
*
|
||||
* Vive su file (storage/app/piantine/), non su tabelle: c'è una sola area
|
||||
* attiva, caricare un KML nuovo la sostituisce e i poligoni già disegnati
|
||||
* restano al loro posto. Stando dentro storage/, entra nei backup completi
|
||||
* senza bisogno di altro.
|
||||
* restano al loro posto.
|
||||
*
|
||||
* ATTENZIONE: NON entra nel backup completo. BackupArchive impacchetta il solo
|
||||
* disco `public` (storage/app/public), e questa cartella gli sta accanto, non
|
||||
* dentro. Dopo un ripristino su volumi vuoti l'area va ricaricata a mano dal
|
||||
* KML originale — che quindi conviene conservare fuori di qui.
|
||||
*/
|
||||
class PiantineArea
|
||||
{
|
||||
@@ -64,33 +68,14 @@ class PiantineArea
|
||||
public function config(): array
|
||||
{
|
||||
if (! Storage::disk('local')->exists(self::FILE_CONFIG)) {
|
||||
return ['localita' => null, 'kml_nome' => null, 'aggiornato_il' => null];
|
||||
return ['kml_nome' => null, 'aggiornato_il' => null];
|
||||
}
|
||||
|
||||
$decoded = json_decode(Storage::disk('local')->get(self::FILE_CONFIG), true);
|
||||
|
||||
return is_array($decoded)
|
||||
? $decoded + ['localita' => null, 'kml_nome' => null, 'aggiornato_il' => null]
|
||||
: ['localita' => null, 'kml_nome' => null, 'aggiornato_il' => null];
|
||||
}
|
||||
|
||||
/** La "Località" stampata sull'intestazione del modulo S-12. */
|
||||
public function localita(): string
|
||||
{
|
||||
$config = $this->config();
|
||||
|
||||
if (! empty($config['localita'])) {
|
||||
return $config['localita'];
|
||||
}
|
||||
|
||||
return (string) \App\Models\Setting::getValue('congregazione_nome', '');
|
||||
}
|
||||
|
||||
public function impostaLocalita(?string $localita): void
|
||||
{
|
||||
$config = $this->config();
|
||||
$config['localita'] = $localita !== null && trim($localita) !== '' ? trim($localita) : null;
|
||||
$this->salvaConfig($config);
|
||||
? $decoded + ['kml_nome' => null, 'aggiornato_il' => null]
|
||||
: ['kml_nome' => null, 'aggiornato_il' => null];
|
||||
}
|
||||
|
||||
private function salvaConfig(array $config): void
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Piantine;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Stato della rigenerazione massiva delle piantine.
|
||||
*
|
||||
* Stessa impostazione di BackupOperationState e TerritorioPdfImportState:
|
||||
* la pagina segue il lavoro in coda interrogando questo stato, la barra si
|
||||
* muove su unità di lavoro completate davvero (territori renderizzati).
|
||||
* Un'operazione alla volta: due rigenerazioni parallele martellerebbero il
|
||||
* renderer e il tile server senza alcun beneficio.
|
||||
*/
|
||||
class PiantineOperationState
|
||||
{
|
||||
protected int $ttlSeconds = 86400;
|
||||
|
||||
private const MAX_LOG = 60;
|
||||
private const CHIAVE_CORRENTE = 'piantine-op:corrente';
|
||||
private const TTL_CORRENTE = 3600;
|
||||
|
||||
public function initialize(string $id, int $total, bool $anchePdf): array
|
||||
{
|
||||
$state = [
|
||||
'id' => $id,
|
||||
'status' => 'queued',
|
||||
'anche_pdf' => $anchePdf,
|
||||
'total' => $total,
|
||||
'processed' => 0,
|
||||
'falliti' => 0,
|
||||
'logs' => [],
|
||||
'error' => null,
|
||||
'started_at' => null,
|
||||
'finished_at' => null,
|
||||
];
|
||||
|
||||
$this->put($id, $state);
|
||||
Cache::put(self::CHIAVE_CORRENTE, $id, self::TTL_CORRENTE);
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
public function corrente(): ?string
|
||||
{
|
||||
$id = Cache::get(self::CHIAVE_CORRENTE);
|
||||
|
||||
if (! is_string($id) || $id === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stato = $this->get($id);
|
||||
|
||||
if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) {
|
||||
Cache::forget(self::CHIAVE_CORRENTE);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function get(string $id): ?array
|
||||
{
|
||||
return Cache::get($this->key($id));
|
||||
}
|
||||
|
||||
public function put(string $id, array $state): void
|
||||
{
|
||||
Cache::put($this->key($id), $state, $this->ttlSeconds);
|
||||
}
|
||||
|
||||
public function update(string $id, callable $callback): void
|
||||
{
|
||||
$state = $this->get($id);
|
||||
|
||||
if (! $state) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->put($id, $callback($state) ?? $state);
|
||||
}
|
||||
|
||||
public function log(string $id, string $message): void
|
||||
{
|
||||
$this->update($id, function (array $state) use ($message) {
|
||||
$state['logs'][] = '[' . now()->format('H:i:s') . '] ' . $message;
|
||||
$state['logs'] = array_slice($state['logs'], -self::MAX_LOG);
|
||||
|
||||
return $state;
|
||||
});
|
||||
}
|
||||
|
||||
public function avanza(string $id, int $processed, int $falliti): void
|
||||
{
|
||||
$this->update($id, function (array $state) use ($processed, $falliti) {
|
||||
$state['processed'] = $processed;
|
||||
$state['falliti'] = $falliti;
|
||||
|
||||
return $state;
|
||||
});
|
||||
}
|
||||
|
||||
public function markRunning(string $id): void
|
||||
{
|
||||
$this->update($id, function (array $state) {
|
||||
$state['status'] = 'running';
|
||||
$state['started_at'] ??= now()->toDateTimeString();
|
||||
|
||||
return $state;
|
||||
});
|
||||
}
|
||||
|
||||
public function markCompleted(string $id): void
|
||||
{
|
||||
$this->update($id, function (array $state) {
|
||||
$state['status'] = 'completed';
|
||||
$state['finished_at'] = now()->toDateTimeString();
|
||||
|
||||
return $state;
|
||||
});
|
||||
|
||||
$this->liberaCorrente($id);
|
||||
}
|
||||
|
||||
public function markFailed(string $id, string $error): void
|
||||
{
|
||||
$this->update($id, function (array $state) use ($error) {
|
||||
$state['status'] = 'failed';
|
||||
$state['error'] = $error;
|
||||
$state['finished_at'] = now()->toDateTimeString();
|
||||
|
||||
return $state;
|
||||
});
|
||||
|
||||
$this->log($id, 'ERRORE: ' . $error);
|
||||
$this->liberaCorrente($id);
|
||||
}
|
||||
|
||||
private function liberaCorrente(string $id): void
|
||||
{
|
||||
if (Cache::get(self::CHIAVE_CORRENTE) === $id) {
|
||||
Cache::forget(self::CHIAVE_CORRENTE);
|
||||
}
|
||||
}
|
||||
|
||||
protected function key(string $id): string
|
||||
{
|
||||
return 'piantine-op:' . $id;
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,48 @@ return [
|
||||
// Secondi concessi a un singolo rendering: comprende il download delle
|
||||
// tessere OSM non ancora in cache, che alla prima generazione può
|
||||
// richiedere qualche decina di secondi.
|
||||
'renderer_timeout' => (int) env('MAP_RENDERER_TIMEOUT', 180),
|
||||
//
|
||||
// Sta sotto `max_execution_time` (120s) di proposito: la generazione parte
|
||||
// da una richiesta web, e un limite piu' alto verrebbe comunque tagliato da
|
||||
// PHP con un errore fatale invece che dal messaggio gestito.
|
||||
'renderer_timeout' => (int) env('MAP_RENDERER_TIMEOUT', 110),
|
||||
|
||||
// Lato massimo (px) dell'immagine generata.
|
||||
'max_side' => 4096,
|
||||
//
|
||||
// Il numero non e' arbitrario: la piantina finisce su una pagina S-12, che
|
||||
// e' una cartolina da 146 x 94,4 mm, e ne occupa il 98%. A 600 dpi — il
|
||||
// massimo che una stampante da ufficio sfrutti davvero — il lato lungo e'
|
||||
// 143 mm / 25,4 * 600 = 3378 px. Oltre, i pixel finiscono nel file senza
|
||||
// arrivare sulla carta.
|
||||
'max_side' => 3400,
|
||||
|
||||
// Margine di contesto attorno al confine, in frazione del lato maggiore.
|
||||
'margin_ratio' => 0.15,
|
||||
|
||||
// Proporzioni dell'immagine: quelle della pagina S-12 (146 / 94,4 mm).
|
||||
//
|
||||
// Senza, un territorio quasi quadrato stampato su una pagina orizzontale
|
||||
// lascerebbe due fasce bianche larghe un quinto di foglio. Chiedendole al
|
||||
// renderer, l'immagine nasce gia' della forma giusta e riempie la pagina
|
||||
// mostrando piu' dintorni invece di carta vuota.
|
||||
'aspetto' => 146 / 94.4,
|
||||
|
||||
// Spessore del confine, in pixel dell'immagine finale.
|
||||
//
|
||||
// Deve dire dove passa il confine senza cancellare la via che ci corre
|
||||
// sotto: chi percorre il territorio quella via la deve leggere. Su 3400 px
|
||||
// stampati su 146 mm, 3 px sono circa un decimo di millimetro — un segno
|
||||
// netto e sottile. Semitrasparente per lo stesso motivo.
|
||||
'tratto' => 3,
|
||||
'tratto_opacita' => 0.85,
|
||||
|
||||
// Formato dell'immagine.
|
||||
//
|
||||
// JPEG e non PNG: una mappa e' un'immagine fotografica (migliaia di tinte
|
||||
// fra edifici, verde e strade) e il PNG, che non perde niente, la porta a
|
||||
// 4-6 MB per territorio — con 274 territori sono diversi GB di volume, e
|
||||
// ogni PDF diventa lento da aprire. A qualita' 86 i nomi delle strade
|
||||
// restano leggibili e il file scende sotto il megabyte.
|
||||
'formato' => env('MAP_RENDERER_FORMATO', 'jpeg'),
|
||||
'qualita' => (int) env('MAP_RENDERER_QUALITA', 86),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
// Sgancia la piantina dal territorio.
|
||||
//
|
||||
// Prima una piantina esisteva solo come disegno di un territorio gia' a
|
||||
// registro: si sceglieva il numero da un elenco e senza quello non si poteva
|
||||
// disegnare. Ma il lavoro reale va nell'ordine opposto — si guarda la mappa,
|
||||
// si taglia l'area, e solo dopo si decide che numero darle; a volte quel
|
||||
// territorio va creato apposta.
|
||||
//
|
||||
// Da qui tre cambiamenti:
|
||||
//
|
||||
// territorio_id diventa nullable una piantina puo' vivere scollegata.
|
||||
// L'indice unico resta: piu' righe NULL sono
|
||||
// ammesse (sia MariaDB sia SQLite), ma un
|
||||
// territorio collegato resta uno solo.
|
||||
//
|
||||
// la cascata diventa nullOnDelete cancellare un territorio non porta con se'
|
||||
// il disegno. Vale anche per l'import XML,
|
||||
// che svuota `territori` in blocco: prima si
|
||||
// portava dietro tutte le piantine, adesso
|
||||
// le lascia scollegate e recuperabili.
|
||||
//
|
||||
// nome l'etichetta di una piantina non collegata.
|
||||
// Con il territorio, comanda il suo numero.
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||
$table->dropForeign(['territorio_id']);
|
||||
});
|
||||
|
||||
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||
$table->foreignId('territorio_id')->nullable()->change();
|
||||
$table->string('nome')->nullable()->after('territorio_id');
|
||||
});
|
||||
|
||||
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||
$table->foreign('territorio_id')->references('id')->on('territori')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Le piantine scollegate non hanno un territorio a cui tornare: senza
|
||||
// questa pulizia il vincolo NOT NULL non si potrebbe piu' applicare.
|
||||
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||
$table->dropForeign(['territorio_id']);
|
||||
});
|
||||
|
||||
\Illuminate\Support\Facades\DB::table('territorio_mappe')->whereNull('territorio_id')->delete();
|
||||
|
||||
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||
$table->dropColumn('nome');
|
||||
$table->foreignId('territorio_id')->nullable(false)->change();
|
||||
});
|
||||
|
||||
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||
$table->foreign('territorio_id')->references('id')->on('territori')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
// La Localita' stampata sull'intestazione dell'S-12 diventa un dato della
|
||||
// singola piantina.
|
||||
//
|
||||
// Prima era una sola per tutta l'installazione, tenuta in
|
||||
// storage/app/piantine/config.json. Regge finche' la congregazione copre un
|
||||
// posto solo; basta un territorio in una frazione o in un comune vicino e
|
||||
// quell'unico valore diventa falso su tutte le cartoline tranne le sue.
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||
$table->string('localita')->nullable()->after('nome');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||
$table->dropColumn('localita');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
// La Localita' stampata sull'S-12 viene dalla zona del territorio.
|
||||
//
|
||||
// Era diventata un campo suo sulla piantina, ed era un doppione: la zona esiste
|
||||
// gia', dice la stessa cosa ("Palermo Montepellegrino") ed e' quella che si
|
||||
// aggiorna quando la congregazione riorganizza il lavoro. Due posti dove
|
||||
// scrivere lo stesso nome vuol dire che prima o poi divergono, e sulla carta
|
||||
// finisce quello sbagliato.
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||
$table->dropColumn('localita');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||
$table->string('localita')->nullable()->after('nome');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -7,7 +7,8 @@
|
||||
*
|
||||
* Non tocca il database ne' i file dell'applicazione: parla solo HTTP.
|
||||
*
|
||||
* POST /render { geometry, marginRatio?, maxSide?, maxZoom?, ... } -> image/png
|
||||
* POST /render { geometry, marginRatio?, maxSide?, aspect?, strokeWidth?, format?, ... }
|
||||
* -> image/png | image/jpeg
|
||||
* GET /healthz -> 200 "ok"
|
||||
*
|
||||
* Le tessere vengono scaricate da tile.openstreetmap.org nel rispetto della
|
||||
@@ -29,6 +30,8 @@ const USER_AGENT = process.env.TILE_USER_AGENT
|
||||
const TILE_SIZE = 256;
|
||||
const FETCH_CONCURRENCY = 6;
|
||||
const MAX_TILES = 1600; // valvola: nessuna richiesta legittima arriva qui
|
||||
// Quanto si compone piu' grande del risultato voluto, prima di ridurre.
|
||||
const SUPERCAMPIONAMENTO = 2;
|
||||
|
||||
// ─── Proiezione Web Mercator ────────────────────────────────────────────────
|
||||
|
||||
@@ -151,11 +154,28 @@ async function render(body) {
|
||||
const rings = extractRings(body.geometry);
|
||||
const marginRatio = Math.min(Math.max(Number(body.marginRatio ?? 0.15), 0), 1);
|
||||
const maxSide = Math.min(Math.max(Number(body.maxSide ?? 4096), 512), 8192);
|
||||
const maxZoom = Math.min(Math.max(Number(body.maxZoom ?? 18), 10), 19);
|
||||
// 19 e' l'ultimo livello che OSM serve: partire da 18 buttava via il piu'
|
||||
// dettagliato proprio sui territori piccoli, dove serve di piu'.
|
||||
const maxZoom = Math.min(Math.max(Number(body.maxZoom ?? 19), 10), 19);
|
||||
const dimOpacity = Math.min(Math.max(Number(body.dimOpacity ?? 0.55), 0), 0.9);
|
||||
const strokeColor = /^#[0-9a-fA-F]{6}$/.test(body.strokeColor ?? '') ? body.strokeColor : '#d81b1b';
|
||||
// Proporzioni volute (larghezza/altezza). Assente: quelle naturali del
|
||||
// territorio.
|
||||
const aspect = body.aspect ? Math.min(Math.max(Number(body.aspect), 0.2), 5) : null;
|
||||
// Spessore del confine in pixel dell'immagine finale.
|
||||
const strokeWidth = Math.min(Math.max(Number(body.strokeWidth ?? 3), 1), 30);
|
||||
const strokeOpacity = Math.min(Math.max(Number(body.strokeOpacity ?? 0.85), 0.2), 1);
|
||||
const attribution = body.attribution === false ? null : '© OpenStreetMap contributors';
|
||||
|
||||
// JPEG per quello che finisce stampato (una mappa e' un'immagine
|
||||
// fotografica: il PNG la porta a qualche megabyte senza guadagno visibile
|
||||
// sulla carta), PNG quando serve il pixel esatto.
|
||||
const format = body.format === 'png' ? 'png' : 'jpeg';
|
||||
const quality = Math.min(Math.max(Number(body.quality ?? 86), 40), 100);
|
||||
const codifica = (pipeline) => format === 'png'
|
||||
? pipeline.png()
|
||||
: pipeline.jpeg({ quality, mozjpeg: true, chromaSubsampling: '4:4:4' });
|
||||
|
||||
// Riquadro geografico del poligono.
|
||||
let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity;
|
||||
for (const ring of rings) {
|
||||
@@ -165,14 +185,24 @@ async function render(body) {
|
||||
}
|
||||
}
|
||||
|
||||
// Zoom piu' alto (max maxZoom) al quale il riquadro, margine compreso,
|
||||
// sta dentro maxSide pixel: massima definizione utile senza esplodere.
|
||||
// Zoom piu' alto (max maxZoom) al quale il riquadro, margine compreso, sta
|
||||
// dentro maxSide * SUPERCAMPIONAMENTO pixel.
|
||||
//
|
||||
// Il fattore non e' spreco: le etichette delle strade OSM sono disegnate
|
||||
// dentro la tessera a una dimensione fissa, quindi salire di uno zoom
|
||||
// significa averle piu' grandi e piu' definite. Componendo a quella scala e
|
||||
// riducendo alla fine si porta quella nitidezza dentro un'immagine della
|
||||
// dimensione voluta — che e' esattamente quello che serve per la stampa,
|
||||
// dove i pixel oltre i 600 dpi non arrivano sulla carta ma il testo sfocato
|
||||
// si vede. Ridurre costa anche meno byte: il rumore di ricampionamento
|
||||
// sparisce e il JPEG comprime meglio di quanto farebbe sull'originale.
|
||||
let zoom = maxZoom;
|
||||
const tettoComposizione = maxSide * SUPERCAMPIONAMENTO;
|
||||
for (; zoom > 1; zoom--) {
|
||||
const [x0, y0] = project(minLng, maxLat, zoom);
|
||||
const [x1, y1] = project(maxLng, minLat, zoom);
|
||||
const longest = Math.max(x1 - x0, y1 - y0) * (1 + marginRatio * 2);
|
||||
if (longest <= maxSide) {
|
||||
if (longest <= tettoComposizione) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -182,10 +212,35 @@ async function render(body) {
|
||||
const margin = Math.round(Math.max(pxMaxX - pxMinX, pxMaxY - pxMinY) * marginRatio);
|
||||
|
||||
const worldSize = TILE_SIZE * 2 ** zoom;
|
||||
const cropMinX = Math.max(0, Math.floor(pxMinX - margin));
|
||||
const cropMinY = Math.max(0, Math.floor(pxMinY - margin));
|
||||
const cropMaxX = Math.min(worldSize, Math.ceil(pxMaxX + margin));
|
||||
const cropMaxY = Math.min(worldSize, Math.ceil(pxMaxY + margin));
|
||||
let cropMinX = Math.max(0, Math.floor(pxMinX - margin));
|
||||
let cropMinY = Math.max(0, Math.floor(pxMinY - margin));
|
||||
let cropMaxX = Math.min(worldSize, Math.ceil(pxMaxX + margin));
|
||||
let cropMaxY = Math.min(worldSize, Math.ceil(pxMaxY + margin));
|
||||
|
||||
// Porta il ritaglio alle proporzioni chieste, allargando il lato corto.
|
||||
//
|
||||
// Serve perche' l'immagine finisce su una pagina di forma fissa: un
|
||||
// territorio quasi quadrato su una cartolina orizzontale, tenendo le sue
|
||||
// proporzioni, lascerebbe due fasce bianche larghe un quinto di pagina.
|
||||
// Allargare mostra piu' dintorni — che a chi percorre il territorio
|
||||
// servono — invece di stampare carta vuota. Il confine resta comunque al
|
||||
// centro, perche' si cresce in parti uguali sui due lati.
|
||||
if (aspect) {
|
||||
const larghezza = cropMaxX - cropMinX;
|
||||
const altezza = cropMaxY - cropMinY;
|
||||
|
||||
if (larghezza / altezza < aspect) {
|
||||
const voluta = altezza * aspect;
|
||||
const cresce = (voluta - larghezza) / 2;
|
||||
cropMinX = Math.max(0, Math.floor(cropMinX - cresce));
|
||||
cropMaxX = Math.min(worldSize, Math.ceil(cropMaxX + cresce));
|
||||
} else {
|
||||
const voluta = larghezza / aspect;
|
||||
const cresce = (voluta - altezza) / 2;
|
||||
cropMinY = Math.max(0, Math.floor(cropMinY - cresce));
|
||||
cropMaxY = Math.min(worldSize, Math.ceil(cropMaxY + cresce));
|
||||
}
|
||||
}
|
||||
|
||||
const tileMinX = Math.floor(cropMinX / TILE_SIZE);
|
||||
const tileMinY = Math.floor(cropMinY / TILE_SIZE);
|
||||
@@ -199,6 +254,9 @@ async function render(body) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ritaglioW = cropMaxX - cropMinX;
|
||||
const ritaglioH = cropMaxY - cropMinY;
|
||||
|
||||
const jobs = [];
|
||||
for (let ty = tileMinY; ty <= tileMaxY; ty++) {
|
||||
for (let tx = tileMinX; tx <= tileMaxX; tx++) {
|
||||
@@ -229,10 +287,21 @@ async function render(body) {
|
||||
const polygonPath = ringPaths.join(' ');
|
||||
|
||||
// Velo bianco su tutta la tela con il poligono come buco (evenodd):
|
||||
// l'esterno sbiadisce, l'interno resta pieno. Sopra, il confine marcato.
|
||||
// l'esterno sbiadisce, l'interno resta pieno. Sopra, il confine.
|
||||
//
|
||||
// Lo spessore e' espresso in pixel dell'immagine FINALE e riportato qui
|
||||
// alla scala di composizione: il disegno viene fatto grande e poi ridotto,
|
||||
// quindi un numero fisso qui darebbe un tratto piu' o meno grosso a seconda
|
||||
// di quanto si e' ridotto. E il tratto conta: deve dire dove passa il
|
||||
// confine senza cancellare la via che ci corre sotto — chi percorre il
|
||||
// territorio quella via la deve leggere. Semitrasparente per lo stesso
|
||||
// motivo: si vede il segno e si vede cosa copre.
|
||||
const fattoreScala = Math.max(1, Math.max(ritaglioW, ritaglioH) / maxSide);
|
||||
const tratto = Math.max(1, strokeWidth * fattoreScala);
|
||||
|
||||
const overlay = `<svg width="${canvasW}" height="${canvasH}" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0,0H${canvasW}V${canvasH}H0Z ${polygonPath}" fill="#ffffff" fill-opacity="${dimOpacity}" fill-rule="evenodd"/>
|
||||
<path d="${polygonPath}" fill="none" stroke="${strokeColor}" stroke-width="7" stroke-linejoin="round" stroke-opacity="0.9"/>
|
||||
<path d="${polygonPath}" fill="none" stroke="${strokeColor}" stroke-width="${tratto.toFixed(2)}" stroke-linejoin="round" stroke-opacity="${strokeOpacity}"/>
|
||||
</svg>`;
|
||||
|
||||
// Composizione e ritaglio in due passaggi: nella pipeline di sharp
|
||||
@@ -247,32 +316,51 @@ async function render(body) {
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const cropped = await sharp(composto, { limitInputPixels: false })
|
||||
let riduzione = sharp(composto, { limitInputPixels: false })
|
||||
.extract({
|
||||
left: cropMinX - originX,
|
||||
top: cropMinY - originY,
|
||||
width: cropMaxX - cropMinX,
|
||||
height: cropMaxY - cropMinY,
|
||||
})
|
||||
.toBuffer();
|
||||
width: ritaglioW,
|
||||
height: ritaglioH,
|
||||
});
|
||||
|
||||
if (!attribution) {
|
||||
return sharp(cropped).png().toBuffer();
|
||||
// Lanczos: e' il kernel che tiene i bordi delle scritte, dove un bilineare
|
||||
// le impasta.
|
||||
if (Math.max(ritaglioW, ritaglioH) > maxSide) {
|
||||
riduzione = riduzione.resize({
|
||||
width: ritaglioW >= ritaglioH ? maxSide : null,
|
||||
height: ritaglioH > ritaglioW ? maxSide : null,
|
||||
kernel: 'lanczos3',
|
||||
fit: 'inside',
|
||||
});
|
||||
}
|
||||
|
||||
const outW = cropMaxX - cropMinX;
|
||||
const outH = cropMaxY - cropMinY;
|
||||
const cropped = await riduzione.png().toBuffer();
|
||||
const { width: outW, height: outH } = await sharp(cropped).metadata();
|
||||
|
||||
if (!attribution) {
|
||||
return { buffer: await codifica(sharp(cropped)).toBuffer(), format };
|
||||
}
|
||||
|
||||
// L'attribuzione si dimensiona sull'immagine finale, non su una misura
|
||||
// fissa. La licenza ODbL la vuole leggibile, e 22 px su un'immagine da
|
||||
// 3400 px sono meno di un millimetro sulla carta: presente nel file,
|
||||
// invisibile a chi tiene in mano la cartolina. Legata alla larghezza resta
|
||||
// della stessa misura apparente a ogni risoluzione.
|
||||
const label = svgEscape(attribution);
|
||||
const labelW = Math.min(outW, label.length * 8 + 16);
|
||||
const attributionSvg = `<svg width="${labelW}" height="22" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="${labelW}" height="22" fill="#ffffff" fill-opacity="0.75"/>
|
||||
<text x="${labelW - 8}" y="15" text-anchor="end" font-family="DejaVu Sans, Arial, sans-serif" font-size="12" fill="#333333">${label}</text>
|
||||
const corpo = Math.max(12, Math.round(outW / 145));
|
||||
const barraH = Math.round(corpo * 1.9);
|
||||
const labelW = Math.min(outW, Math.round(label.length * corpo * 0.55 + corpo * 2));
|
||||
|
||||
const attributionSvg = `<svg width="${labelW}" height="${barraH}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="${labelW}" height="${barraH}" fill="#ffffff" fill-opacity="0.78"/>
|
||||
<text x="${labelW - corpo}" y="${Math.round(barraH * 0.72)}" text-anchor="end" font-family="DejaVu Sans, Arial, sans-serif" font-size="${corpo}" fill="#333333">${label}</text>
|
||||
</svg>`;
|
||||
|
||||
return sharp(cropped)
|
||||
.composite([{ input: Buffer.from(attributionSvg), left: outW - labelW, top: outH - 22 }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
const finale = sharp(cropped)
|
||||
.composite([{ input: Buffer.from(attributionSvg), left: outW - labelW, top: outH - barraH }]);
|
||||
|
||||
return { buffer: await codifica(finale).toBuffer(), format };
|
||||
}
|
||||
|
||||
// ─── HTTP ───────────────────────────────────────────────────────────────────
|
||||
@@ -314,9 +402,12 @@ const server = http.createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const png = await render(body);
|
||||
res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': png.length });
|
||||
res.end(png);
|
||||
const { buffer, format } = await render(body);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': format === 'png' ? 'image/png' : 'image/jpeg',
|
||||
'Content-Length': buffer.length,
|
||||
});
|
||||
res.end(buffer);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"name": "app",
|
||||
"name": "termanager2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@fontsource-variable/figtree": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5",
|
||||
"@tailwindcss/vite": "^4.0",
|
||||
@@ -458,6 +461,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/figtree": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/figtree/-/figtree-5.3.0.tgz",
|
||||
"integrity": "sha512-VRVodD7OiG7apQwE8/2fMjaqpY/a0qRJqL5HtYj8CKIVps+9amaFcdfKmDIf7iqYQx6iPLlArVXAfKeIZmVcNQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
||||
@@ -15,5 +15,8 @@
|
||||
"leaflet-draw": "^1.0.4",
|
||||
"tailwindcss": "^4.0",
|
||||
"vite": "^6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/figtree": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
// Editor delle piantine (pagina /piantine, solo amministratore).
|
||||
// Editor delle piantine (pagina /piantine/editor, solo amministratore).
|
||||
//
|
||||
// Mappa Leaflet su tessere OpenStreetMap: mostra l'area del KML, i poligoni
|
||||
// dei territori già salvati e permette di disegnarne di nuovi con
|
||||
// leaflet-draw. Parla con il componente Livewire attraverso eventi:
|
||||
// JS → Livewire: 'poligono-disegnato', 'territorio-selezionato-mappa'
|
||||
// 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'
|
||||
|
||||
import L from 'leaflet';
|
||||
@@ -11,10 +11,13 @@ import 'leaflet/dist/leaflet.css';
|
||||
import 'leaflet-draw';
|
||||
import 'leaflet-draw/dist/leaflet.draw.css';
|
||||
|
||||
const STILE_AREA = { color: '#2563eb', weight: 2, dashArray: '8 6', fill: false, interactive: false };
|
||||
const STILE_SALVATO = { color: '#6b7280', weight: 2, fillColor: '#6b7280', fillOpacity: 0.12 };
|
||||
const STILE_SELEZIONATO = { color: '#4f46e5', weight: 3, fillColor: '#6366f1', fillOpacity: 0.2 };
|
||||
const STILE_IN_ATTESA = { color: '#d81b1b', weight: 3, fillColor: '#ef4444', fillOpacity: 0.15 };
|
||||
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 };
|
||||
|
||||
function initPiantine() {
|
||||
const contenitore = document.getElementById('piantine-map');
|
||||
@@ -23,7 +26,7 @@ function initPiantine() {
|
||||
}
|
||||
contenitore.dataset.inizializzato = '1';
|
||||
|
||||
let dati = { area: null, poligoni: [], selezionato: null };
|
||||
let dati = { area: null, piantine: [], selezionata: null };
|
||||
try {
|
||||
dati = JSON.parse(document.getElementById('piantine-dati').textContent);
|
||||
} catch {
|
||||
@@ -31,25 +34,27 @@ function initPiantine() {
|
||||
}
|
||||
|
||||
// Etichette in italiano per la barra di disegno.
|
||||
L.drawLocal.draw.toolbar.buttons.polygon = 'Disegna il poligono di un territorio';
|
||||
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 poligono.',
|
||||
start: 'Clicca sulla mappa per iniziare il confine.',
|
||||
cont: 'Continua a cliccare lungo il confine.',
|
||||
end: 'Clicca sul primo punto per chiudere il poligono.',
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
let layerArea = null;
|
||||
const layerSalvati = L.featureGroup().addTo(mappa);
|
||||
const layerSalvate = L.featureGroup().addTo(mappa);
|
||||
let layerInAttesa = null;
|
||||
|
||||
mappa.addControl(new L.Control.Draw({
|
||||
@@ -64,6 +69,11 @@ function initPiantine() {
|
||||
edit: false,
|
||||
}));
|
||||
|
||||
function stileDi(piantina, selezionata) {
|
||||
if (selezionata) return STILE_SELEZIONATA;
|
||||
return piantina.collegata ? STILE_COLLEGATA : STILE_SCOLLEGATA;
|
||||
}
|
||||
|
||||
function ridisegna(nuoviDati, adattaVista) {
|
||||
dati = nuoviDati;
|
||||
|
||||
@@ -75,24 +85,37 @@ function initPiantine() {
|
||||
layerArea = L.geoJSON(dati.area, { style: STILE_AREA, interactive: false }).addTo(mappa);
|
||||
}
|
||||
|
||||
layerSalvati.clearLayers();
|
||||
for (const poligono of dati.poligoni) {
|
||||
const selezionato = dati.selezionato === poligono.territorioId;
|
||||
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: poligono.geometry },
|
||||
{ style: selezionato ? STILE_SELEZIONATO : STILE_SALVATO }
|
||||
{ type: 'Feature', properties: {}, geometry: piantina.geometry },
|
||||
{ style: stileDi(piantina, selezionata) }
|
||||
);
|
||||
layer.bindTooltip(`Terr. ${poligono.numero}`, { sticky: true });
|
||||
layer.bindTooltip(piantina.etichetta, { sticky: true });
|
||||
layer.on('click', () => {
|
||||
window.Livewire.dispatch('territorio-selezionato-mappa', { territorioId: poligono.territorioId });
|
||||
window.Livewire.dispatch('piantina-selezionata-mappa', { mappaId: piantina.mappaId });
|
||||
});
|
||||
layerSalvati.addLayer(layer);
|
||||
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.poligoni.length ? layerSalvati : null);
|
||||
const riferimento = layerArea ?? (dati.piantine.length ? layerSalvate : null);
|
||||
if (riferimento) {
|
||||
mappa.fitBounds(riferimento.getBounds().pad(0.08));
|
||||
} else {
|
||||
@@ -124,16 +147,22 @@ function initPiantine() {
|
||||
return;
|
||||
}
|
||||
registrato = true;
|
||||
window.Livewire.on('piantine-dati-aggiornati', ({ dati: nuovi }) => {
|
||||
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, false);
|
||||
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);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it" class="h-full bg-gray-50">
|
||||
<html lang="it" class="h-full bg-gray-50 tm-app">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -9,6 +9,8 @@
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="h-full">
|
||||
{{-- Chi naviga da tastiera non deve ripercorrere il menu a ogni pagina. --}}
|
||||
<a href="#contenuto" class="tm-salta">Salta al contenuto</a>
|
||||
<div class="min-h-full">
|
||||
{{-- Header --}}
|
||||
<nav class="header-gradient shadow-lg">
|
||||
@@ -179,7 +181,7 @@
|
||||
</aside>
|
||||
|
||||
{{-- Main content --}}
|
||||
<main class="flex-1 px-4 sm:px-6 lg:px-8 py-6 min-h-screen">
|
||||
<main id="contenuto" tabindex="-1" class="flex-1 px-4 sm:px-6 lg:px-8 py-6 min-h-screen">
|
||||
{{-- Flash messages --}}
|
||||
@if (session()->has('success'))
|
||||
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)"
|
||||
|
||||
55
resources/views/components/layouts/editor.blade.php
Normal file
55
resources/views/components/layouts/editor.blade.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
{{--
|
||||
Telaio a schermo intero, senza barra laterale e senza testata.
|
||||
|
||||
Serve a una cosa sola: la mappa dell'editor delle piantine, che deve
|
||||
prendersi tutta la finestra. I messaggi non spingono il contenuto verso il
|
||||
basso come nelle altre pagine — comparirebbe una fascia che sposta la mappa
|
||||
mentre ci si sta disegnando sopra — ma galleggiano sopra, in alto al centro,
|
||||
e si tolgono da soli.
|
||||
--}}
|
||||
<html lang="it" class="h-full tm-app">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title ?? 'TerManager2' }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="h-full overflow-hidden" style="background: var(--tm-fondo)">
|
||||
<main class="tm-schermo">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
|
||||
<div class="tm-avvisi" role="status" aria-live="polite">
|
||||
@if (session()->has('success'))
|
||||
<div x-data="{ mostra: true }" x-show="mostra" x-cloak
|
||||
x-init="setTimeout(() => mostra = false, 4500)"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="tm-avviso tm-avviso-ok">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
<span>{{ session('success') }}</span>
|
||||
<button type="button" @click="mostra = false" class="tm-avviso-chiudi" aria-label="Chiudi il messaggio">×</button>
|
||||
</div>
|
||||
@endif
|
||||
@if (session()->has('error'))
|
||||
<div x-data="{ mostra: true }" x-show="mostra" x-cloak
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="tm-avviso tm-avviso-errore">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span>{{ session('error') }}</span>
|
||||
<button type="button" @click="mostra = false" class="tm-avviso-chiudi" aria-label="Chiudi il messaggio">×</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
204
resources/views/livewire/piantine/piantine-editor.blade.php
Normal file
204
resources/views/livewire/piantine/piantine-editor.blade.php
Normal file
@@ -0,0 +1,204 @@
|
||||
<div class="tm-editor">
|
||||
@vite('resources/js/piantine.js')
|
||||
|
||||
{{-- ── Barra strumenti ───────────────────────────────────────────────── --}}
|
||||
<header class="tm-editor-barra">
|
||||
<a href="{{ route('piantine.index') }}" class="tm-editor-torna">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||
<span>Piantine</span>
|
||||
</a>
|
||||
|
||||
<span class="tm-editor-sep" aria-hidden="true"></span>
|
||||
|
||||
<h1 class="tm-editor-titolo">
|
||||
{{ $mappa?->etichetta() ?? 'Nuova piantina' }}
|
||||
@if($geometriaInAttesa)
|
||||
<span class="tm-tag tm-tag-attesa">disegno non salvato</span>
|
||||
@elseif($mappa && ! $mappa->collegata())
|
||||
<span class="tm-tag">scollegata</span>
|
||||
@endif
|
||||
</h1>
|
||||
|
||||
<div class="tm-editor-azioni">
|
||||
<x-btn variante="secondary" dimensione="sm" wire:click="nuova">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
Nuova
|
||||
</x-btn>
|
||||
|
||||
<x-btn variante="secondary" dimensione="sm" wire:click="$toggle('pannelloAperto')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/></svg>
|
||||
Scheda
|
||||
</x-btn>
|
||||
|
||||
@if($mappa)
|
||||
<x-btn variante="secondary" dimensione="sm"
|
||||
wire:click="generaImmagine" wire:loading.attr="disabled" wire:target="generaImmagine">
|
||||
<span wire:loading.remove wire:target="generaImmagine">Genera immagine</span>
|
||||
<span wire:loading wire:target="generaImmagine">Rendering…</span>
|
||||
</x-btn>
|
||||
|
||||
@if($mappa->collegata() && $mappa->image_path)
|
||||
<x-btn variante="secondary" dimensione="sm"
|
||||
:href="route('piantine.anteprima', $mappa)" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
Anteprima PDF
|
||||
</x-btn>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<x-btn variante="primary" dimensione="sm" wire:click="salva"
|
||||
wire:loading.attr="disabled" wire:target="salva">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
Salva
|
||||
</x-btn>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{-- ── Mappa ─────────────────────────────────────────────────────────── --}}
|
||||
<div class="tm-editor-mappa">
|
||||
<div wire:ignore id="piantine-map" class="tm-mappa"></div>
|
||||
|
||||
@unless($areaCaricata)
|
||||
<p class="tm-editor-nota">
|
||||
Nessuna area città caricata: la mappa parte senza perimetro di riferimento.
|
||||
Il KML si carica dalla <a href="{{ route('piantine.index') }}">pagina di gestione</a>.
|
||||
</p>
|
||||
@endunless
|
||||
|
||||
{{-- Pannello: scorre sopra la mappa, non la restringe. --}}
|
||||
<aside class="tm-editor-pannello" @if(! $pannelloAperto) hidden @endif
|
||||
aria-label="Scheda della piantina">
|
||||
<div class="tm-editor-pannello-testata">
|
||||
<h2>Scheda</h2>
|
||||
<button type="button" wire:click="chiudiPannello" class="tm-editor-chiudi" aria-label="Chiudi il pannello">×</button>
|
||||
</div>
|
||||
|
||||
<div class="tm-editor-pannello-corpo">
|
||||
@if($geometriaInAttesa)
|
||||
<div class="tm-nota tm-nota-attesa">
|
||||
<p>C'è un poligono disegnato non ancora salvato.</p>
|
||||
<x-btn variante="ghost" dimensione="sm" wire:click="scartaDisegno">Scarta il disegno</x-btn>
|
||||
</div>
|
||||
@elseif(! $mappa)
|
||||
<p class="tm-editor-aiuto">
|
||||
Usa lo strumento poligono in alto a sinistra sulla mappa per tracciare il confine.
|
||||
La scheda del territorio si compila qui sotto, anche dopo.
|
||||
</p>
|
||||
@endif
|
||||
|
||||
{{-- ── La piantina ───────────────────────────────────────── --}}
|
||||
<fieldset class="tm-gruppo">
|
||||
<legend>La piantina</legend>
|
||||
|
||||
<div class="tm-campo">
|
||||
<label for="nome">Nome interno</label>
|
||||
<input type="text" id="nome" wire:model="nome" autocomplete="off"
|
||||
placeholder="Es. Zona porto — lato nord">
|
||||
<p class="tm-campo-aiuto">
|
||||
Serve solo a ritrovarla in elenco finché non è collegata a un territorio.
|
||||
Non viene stampato.
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{{-- ── Il territorio ─────────────────────────────────────── --}}
|
||||
<fieldset class="tm-gruppo">
|
||||
<legend>Il territorio</legend>
|
||||
|
||||
<div class="tm-campo">
|
||||
<label for="territorioScelto">Collegamento</label>
|
||||
<select id="territorioScelto" wire:model.live="territorioScelto">
|
||||
<option value="">— nessuno, piantina scollegata —</option>
|
||||
<option value="nuovo">+ Crea un territorio nuovo</option>
|
||||
@foreach($territori as $territorio)
|
||||
<option value="{{ $territorio->id }}">Territorio {{ $territorio->numero }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@if($territorioScelto === '')
|
||||
<p class="tm-campo-aiuto">
|
||||
Senza territorio la piantina si disegna e si renderizza lo stesso; per il PDF
|
||||
sul modulo S-12 serve il collegamento.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($territorioScelto !== '')
|
||||
<div class="tm-campo">
|
||||
<label for="numero">Numero</label>
|
||||
<input type="text" id="numero" wire:model="numero" autocomplete="off"
|
||||
maxlength="20" placeholder="Es. 42">
|
||||
@error('numero') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="tm-campo">
|
||||
<label for="zonaId">Zona <span class="tm-campo-nota">stampata come «Località» sulla cartolina</span></label>
|
||||
<select id="zonaId" wire:model="zonaId">
|
||||
<option value="">— nessuna —</option>
|
||||
@foreach($zone as $zona)
|
||||
<option value="{{ $zona->id }}">{{ $zona->nome }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<div class="tm-campo-riga tm-campo-crea">
|
||||
<input type="text" wire:model="nuovaZona" autocomplete="off"
|
||||
placeholder="Zona che non c'è ancora"
|
||||
aria-label="Nome della nuova zona"
|
||||
wire:keydown.enter.prevent="creaZona">
|
||||
<x-btn variante="secondary" dimensione="sm" wire:click="creaZona">Crea</x-btn>
|
||||
</div>
|
||||
@error('nuovaZona') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="tm-campo">
|
||||
<label for="tipologiaId">Tipologia</label>
|
||||
<select id="tipologiaId" wire:model="tipologiaId">
|
||||
<option value="">— nessuna —</option>
|
||||
@foreach($tipologie as $tipologia)
|
||||
<option value="{{ $tipologia->id }}">{{ $tipologia->nome }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<div class="tm-campo-riga tm-campo-crea">
|
||||
<input type="text" wire:model="nuovaTipologia" autocomplete="off"
|
||||
placeholder="Tipologia che non c'è ancora"
|
||||
aria-label="Nome della nuova tipologia"
|
||||
wire:keydown.enter.prevent="creaTipologia">
|
||||
<x-btn variante="secondary" dimensione="sm" wire:click="creaTipologia">Crea</x-btn>
|
||||
</div>
|
||||
@error('nuovaTipologia') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="tm-campo">
|
||||
<label for="confini">Confini</label>
|
||||
<textarea id="confini" wire:model="confini" rows="6"
|
||||
placeholder="Nord: … Est: … Sud: … Ovest: …"></textarea>
|
||||
@error('confini') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
||||
<p class="tm-campo-aiuto">
|
||||
Vengono stampati sulla prima pagina della cartolina, nello spazio centrale
|
||||
del modulo. Il corpo del testo si adatta da solo alla lunghezza.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</fieldset>
|
||||
|
||||
@if($mappa)
|
||||
<div class="tm-editor-pannello-piede">
|
||||
@if($mappa->image_path)
|
||||
<a href="{{ asset('storage/' . $mappa->image_path) }}" target="_blank" rel="noopener"
|
||||
class="tm-collegamento">
|
||||
Immagine del {{ $mappa->rendered_at?->format('d/m/Y H:i') }}
|
||||
</a>
|
||||
@else
|
||||
<span class="tm-editor-aiuto">Immagine non ancora generata.</span>
|
||||
@endif
|
||||
|
||||
<x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="elimina"
|
||||
wire:confirm="Eliminare {{ $mappa->etichetta() }}? Poligono e immagine vengono rimossi; il territorio e il suo PDF restano.">
|
||||
Elimina piantina
|
||||
</x-btn>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script type="application/json" id="piantine-dati">@json($datiMappaIniziali)</script>
|
||||
</div>
|
||||
@@ -1,221 +1,204 @@
|
||||
<div>
|
||||
@vite('resources/js/piantine.js')
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Piantine dei territori</h1>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
Carica il KML dell'area della città, disegna i poligoni dei territori sulla mappa e genera
|
||||
immagini ad alta definizione e PDF sul modulo S-12. I PDF sostituiti finiscono nello storico
|
||||
versioni, sempre ripristinabili.
|
||||
<header class="tm-pagina-testata">
|
||||
<div>
|
||||
<h1 class="tm-pagina-titolo">Piantine dei territori</h1>
|
||||
<p class="tm-pagina-nota">
|
||||
Il disegno dei confini si fa nell'editor a schermo intero. Qui si tiene l'area della
|
||||
città, si generano immagini e PDF sul modulo S-12, e si ripescano le versioni sostituite.
|
||||
</p>
|
||||
</div>
|
||||
<x-btn variante="primary" :href="route('piantine.editor')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
|
||||
Apri l'editor
|
||||
</x-btn>
|
||||
</header>
|
||||
|
||||
{{-- ── 1. Area della città (KML) ─────────────────────────────────── --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
|
||||
<h2 class="text-base font-semibold text-gray-900 mb-3">1 · Area della città</h2>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<form wire:submit="caricaKml">
|
||||
<label for="kmlFile" class="block text-sm font-medium text-gray-700">File .kml dell'area</label>
|
||||
<div class="mt-1 flex items-center gap-3">
|
||||
<input type="file" id="kmlFile" wire:model="kmlFile" accept=".kml,.xml"
|
||||
class="block w-full text-sm text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-indigo-700 hover:file:bg-indigo-100">
|
||||
<x-btn variante="primary" type="submit" wire:loading.attr="disabled" wire:target="kmlFile,caricaKml">
|
||||
{{-- ── Area della città ──────────────────────────────────────────────── --}}
|
||||
<section class="tm-card tm-card-primaria {{ $config['kml_nome'] ? '' : 'tm-card-manca' }}">
|
||||
<div class="tm-card-testata tm-card-testata-icona">
|
||||
<span class="tm-card-icona" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="tm-card-titolo">
|
||||
Area della città
|
||||
@if($config['kml_nome'])
|
||||
<span class="tm-tag tm-tag-ok">caricata</span>
|
||||
@else
|
||||
<span class="tm-tag tm-tag-attesa">da caricare</span>
|
||||
@endif
|
||||
</h2>
|
||||
<p class="tm-card-nota">
|
||||
Il perimetro di riferimento su cui si disegnano le piantine: è il primo passo,
|
||||
senza l'editor parte su una mappa senza contorni.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form wire:submit="caricaKml" class="tm-campo">
|
||||
<label for="kmlFile">File .kml dell'area</label>
|
||||
<div class="tm-campo-riga">
|
||||
<input type="file" id="kmlFile" wire:model="kmlFile" accept=".kml,.xml" class="tm-file">
|
||||
<x-btn variante="secondary" type="submit" wire:loading.attr="disabled" wire:target="kmlFile,caricaKml">
|
||||
Carica
|
||||
</x-btn>
|
||||
</div>
|
||||
<div wire:loading wire:target="kmlFile" class="mt-1 text-xs text-gray-500">Caricamento del file…</div>
|
||||
@error('kmlFile') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
<p wire:loading wire:target="kmlFile" class="tm-campo-aiuto">Caricamento del file…</p>
|
||||
@error('kmlFile') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
||||
|
||||
@if($config['kml_nome'])
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
Area attiva: <span class="font-medium text-gray-700">{{ $config['kml_nome'] }}</span>
|
||||
(aggiornata il {{ $config['aggiornato_il'] }}).
|
||||
Caricare un nuovo KML sostituisce l'area; i poligoni già disegnati restano.
|
||||
<p class="tm-campo-aiuto">
|
||||
Area attiva: <strong>{{ $config['kml_nome'] }}</strong>, aggiornata il {{ $config['aggiornato_il'] }}.
|
||||
Caricare un nuovo KML la sostituisce; le piantine già disegnate restano.
|
||||
</p>
|
||||
@else
|
||||
<p class="mt-2 text-xs text-amber-600">Nessuna area caricata: la mappa parte senza perimetro di riferimento.</p>
|
||||
<p class="tm-campo-aiuto tm-campo-aiuto-attesa">
|
||||
Nessuna area caricata: l'editor parte senza perimetro di riferimento.
|
||||
</p>
|
||||
@endif
|
||||
</form>
|
||||
<form wire:submit="salvaLocalita">
|
||||
<label for="localita" class="block text-sm font-medium text-gray-700">
|
||||
Località (intestazione del modulo S-12)
|
||||
</label>
|
||||
<div class="mt-1 flex items-center gap-3">
|
||||
<input type="text" id="localita" wire:model="localita" placeholder="Es. Palermo — Congregazione …"
|
||||
class="block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
<x-btn variante="secondary" type="submit">Salva</x-btn>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">Se vuota, viene usato il nome della congregazione dalle impostazioni.</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- ── 2. Editor della mappa ─────────────────────────────────────── --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
|
||||
<h2 class="text-base font-semibold text-gray-900 mb-3">2 · Disegno dei territori</h2>
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2">
|
||||
<div wire:ignore id="piantine-map" class="w-full rounded-xl border border-gray-300 z-0" style="height: 65vh; min-height: 420px;"></div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
Usa lo strumento poligono (in alto a sinistra sulla mappa) per disegnare il territorio.
|
||||
Il tratteggio blu è l'area del KML; i poligoni grigi sono i territori già salvati.
|
||||
{{-- ── Piantine ──────────────────────────────────────────────────────── --}}
|
||||
<section class="tm-card">
|
||||
<div class="tm-card-testata">
|
||||
<h2 class="tm-card-titolo">Piantine <span class="tm-conteggio">{{ $piantine->count() }}</span></h2>
|
||||
<p class="tm-card-nota">
|
||||
Una piantina può vivere scollegata: per il PDF sul modulo S-12 serve invece un territorio.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="territorioSelezionato" class="block text-sm font-medium text-gray-700">Territorio da associare</label>
|
||||
<select id="territorioSelezionato" wire:model.live="territorioSelezionato"
|
||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
|
||||
@if($piantine->isEmpty())
|
||||
<div class="tm-vuoto">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
|
||||
<p>Nessuna piantina disegnata.</p>
|
||||
<x-btn variante="primary" dimensione="sm" :href="route('piantine.editor')">Disegna la prima</x-btn>
|
||||
</div>
|
||||
@else
|
||||
<ul class="tm-elenco">
|
||||
@foreach($piantine as $piantina)
|
||||
<li class="tm-elenco-riga" wire:key="piantina-{{ $piantina->id }}">
|
||||
<div class="tm-elenco-intestazione">
|
||||
<a href="{{ route('piantine.editor', $piantina->id) }}" class="tm-elenco-nome">
|
||||
{{ $piantina->etichetta() }}
|
||||
</a>
|
||||
@unless($piantina->collegata())
|
||||
<span class="tm-tag">scollegata</span>
|
||||
@endunless
|
||||
@if($piantina->collegata() && ! $piantina->territorio->zona_id)
|
||||
<span class="tm-tag tm-tag-attesa" title="La zona viene stampata come «Località»: senza, l'intestazione della cartolina resta vuota">Zona mancante</span>
|
||||
@endif
|
||||
<span class="tm-elenco-stato {{ $piantina->image_path ? 'tm-elenco-stato-ok' : '' }}">
|
||||
{{ $piantina->image_path
|
||||
? 'immagine del ' . $piantina->rendered_at?->format('d/m/Y H:i')
|
||||
: 'immagine da generare' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="tm-elenco-azioni">
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm"
|
||||
wire:click="generaImmagine({{ $piantina->id }})"
|
||||
wire:loading.attr="disabled" wire:target="generaImmagine({{ $piantina->id }})">
|
||||
<span wire:loading.remove wire:target="generaImmagine({{ $piantina->id }})">Genera immagine</span>
|
||||
<span wire:loading wire:target="generaImmagine({{ $piantina->id }})">Rendering…</span>
|
||||
</x-btn>
|
||||
|
||||
@if($piantina->image_path)
|
||||
<a href="{{ asset('storage/' . $piantina->image_path) }}" target="_blank" rel="noopener" class="tm-collegamento">Immagine</a>
|
||||
@endif
|
||||
|
||||
@if($piantina->collegata())
|
||||
@if($piantina->image_path)
|
||||
<a href="{{ route('piantine.anteprima', $piantina) }}" target="_blank" rel="noopener" class="tm-collegamento">Anteprima PDF</a>
|
||||
<x-btn variante="primary" tono="tenue" dimensione="sm"
|
||||
wire:click="generaPdf({{ $piantina->id }})"
|
||||
wire:loading.attr="disabled" wire:target="generaPdf({{ $piantina->id }})"
|
||||
wire:confirm="Sostituire il PDF del territorio {{ $piantina->territorio->numero }}? Quello attuale finisce nello storico versioni.">
|
||||
<span wire:loading.remove wire:target="generaPdf({{ $piantina->id }})">Genera PDF</span>
|
||||
<span wire:loading wire:target="generaPdf({{ $piantina->id }})">Composizione…</span>
|
||||
</x-btn>
|
||||
@endif
|
||||
<x-btn variante="ghost" dimensione="sm" wire:click="scollega({{ $piantina->id }})"
|
||||
wire:confirm="Scollegare questa piantina dal territorio {{ $piantina->territorio->numero }}? Il PDF del territorio resta com'è.">
|
||||
Scollega
|
||||
</x-btn>
|
||||
@else
|
||||
<x-btn variante="secondary" dimensione="sm" wire:click="apriCollegamento({{ $piantina->id }})">
|
||||
Collega a un territorio
|
||||
</x-btn>
|
||||
@endif
|
||||
|
||||
<x-btn variante="danger" tono="tenue" dimensione="sm"
|
||||
wire:click="eliminaPiantina({{ $piantina->id }})"
|
||||
wire:confirm="Eliminare {{ $piantina->etichetta() }}? Poligono e immagine vengono rimossi; il PDF del territorio resta com'è.">
|
||||
Elimina
|
||||
</x-btn>
|
||||
</div>
|
||||
|
||||
@if($collegamentoAperto === $piantina->id)
|
||||
<div class="tm-elenco-pannello">
|
||||
<div class="tm-campo-riga">
|
||||
<select wire:model="territorioDaCollegare" aria-label="Territorio a cui collegare la piantina">
|
||||
<option value="">— scegli il territorio —</option>
|
||||
@foreach($territori as $territorio)
|
||||
<option value="{{ $territorio->id }}">
|
||||
{{ $territorio->numero }}{{ $mappe->has($territorio->id) ? ' · poligono presente' : '' }}
|
||||
</option>
|
||||
<option value="{{ $territorio->id }}">{{ $territorio->numero }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
|
||||
@if($geometriaInAttesa)
|
||||
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
C'è un poligono disegnato non ancora salvato.
|
||||
<x-btn variante="primary" dimensione="sm" wire:click="collega({{ $piantina->id }})">Collega</x-btn>
|
||||
<x-btn variante="ghost" dimensione="sm" wire:click="chiudiCollegamento">Annulla</x-btn>
|
||||
</div>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<x-btn variante="primary" wire:click="salvaPoligono" wire:loading.attr="disabled">
|
||||
Salva poligono
|
||||
</x-btn>
|
||||
<x-btn variante="ghost" wire:click="scartaPoligono">Scarta</x-btn>
|
||||
</div>
|
||||
@else
|
||||
<p class="mt-3 text-xs text-gray-500">
|
||||
Disegna un poligono e poi salvalo sul territorio selezionato. Ridisegnare e salvare
|
||||
sostituisce il poligono esistente.
|
||||
<p class="tm-campo-aiuto">
|
||||
Il territorio da creare non c'è? Si crea dall'editor, senza perdere il disegno.
|
||||
</p>
|
||||
@endif
|
||||
|
||||
{{-- Territori con poligono --}}
|
||||
<div class="mt-4 border-t border-gray-200 pt-3">
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-2">Territori con poligono ({{ $mappe->count() }})</h3>
|
||||
<ul class="divide-y divide-gray-100 max-h-72 overflow-y-auto pr-1">
|
||||
@forelse($mappe->sortBy(fn ($m) => [strlen($m->territorio?->numero ?? ''), $m->territorio?->numero]) as $mappa)
|
||||
<li class="py-2 text-sm" wire:key="mappa-{{ $mappa->id }}">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<button type="button" wire:click="$set('territorioSelezionato', {{ $mappa->territorio_id }})"
|
||||
class="font-medium {{ $territorioSelezionato === $mappa->territorio_id ? 'text-indigo-700' : 'text-gray-800' }} hover:underline">
|
||||
Terr. {{ $mappa->territorio?->numero ?? '?' }}
|
||||
</button>
|
||||
<span class="text-xs {{ $mappa->image_path ? 'text-green-600' : 'text-gray-400' }}">
|
||||
{{ $mappa->image_path ? 'immagine del ' . $mappa->rendered_at?->format('d/m/Y H:i') : 'immagine da generare' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<button type="button" wire:click="generaImmagine({{ $mappa->id }})"
|
||||
wire:loading.attr="disabled" wire:target="generaImmagine({{ $mappa->id }})"
|
||||
class="text-indigo-600 hover:underline disabled:opacity-50">
|
||||
<span wire:loading.remove wire:target="generaImmagine({{ $mappa->id }})">Genera immagine</span>
|
||||
<span wire:loading wire:target="generaImmagine({{ $mappa->id }})">Rendering in corso…</span>
|
||||
</button>
|
||||
@if($mappa->image_path)
|
||||
<a href="{{ asset('storage/' . $mappa->image_path) }}" target="_blank" class="text-indigo-600 hover:underline">Scarica immagine</a>
|
||||
<button type="button" wire:click="generaPdf({{ $mappa->id }})"
|
||||
wire:loading.attr="disabled" wire:target="generaPdf({{ $mappa->id }})"
|
||||
wire:confirm="Sostituire il PDF del territorio {{ $mappa->territorio?->numero }}? Quello attuale finisce nello storico versioni."
|
||||
class="text-indigo-600 hover:underline disabled:opacity-50">
|
||||
<span wire:loading.remove wire:target="generaPdf({{ $mappa->id }})">Genera PDF e sostituisci</span>
|
||||
<span wire:loading wire:target="generaPdf({{ $mappa->id }})">Composizione…</span>
|
||||
</button>
|
||||
@endif
|
||||
<button type="button" wire:click="eliminaPoligono({{ $mappa->id }})"
|
||||
wire:confirm="Eliminare poligono e immagine del territorio {{ $mappa->territorio?->numero }}? Il PDF del territorio resta com'è."
|
||||
class="text-red-600 hover:underline">Elimina</button>
|
||||
</div>
|
||||
</li>
|
||||
@empty
|
||||
<li class="py-2 text-sm text-gray-500">Nessun poligono ancora disegnato.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── 3. Rigenerazione massiva ──────────────────────────────────── --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4"
|
||||
@if($stato && in_array($stato['status'], ['queued', 'running'], true)) wire:poll.2s @endif>
|
||||
<h2 class="text-base font-semibold text-gray-900 mb-3">3 · Rigenerazione massiva</h2>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Rigenera in coda le immagini di tutti i territori con poligono (utile dopo aggiornamenti della
|
||||
cartografia). Con la seconda opzione vengono ricomposti e sostituiti anche i PDF.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-btn variante="secondary" wire:click="rigeneraTutto(false)"
|
||||
wire:confirm="Rigenerare le immagini di tutti i territori con poligono?"
|
||||
wire:loading.attr="disabled" :disabled="$stato && in_array($stato['status'], ['queued', 'running'], true)">
|
||||
Rigenera tutte le immagini
|
||||
</x-btn>
|
||||
<x-btn variante="secondary" wire:click="rigeneraTutto(true)"
|
||||
wire:confirm="Rigenerare immagini E sostituire tutti i PDF? Le versioni attuali finiscono nello storico."
|
||||
wire:loading.attr="disabled" :disabled="$stato && in_array($stato['status'], ['queued', 'running'], true)">
|
||||
Rigenera immagini + PDF
|
||||
</x-btn>
|
||||
</div>
|
||||
|
||||
@if($stato)
|
||||
<div class="mt-4">
|
||||
@php
|
||||
$percento = ($stato['total'] ?? 0) > 0 ? (int) round(($stato['processed'] / $stato['total']) * 100) : 0;
|
||||
@endphp
|
||||
<div class="flex items-center justify-between text-xs text-gray-600 mb-1">
|
||||
<span>
|
||||
@if($stato['status'] === 'queued') In coda…
|
||||
@elseif($stato['status'] === 'running') In corso: {{ $stato['processed'] }}/{{ $stato['total'] }}
|
||||
@elseif($stato['status'] === 'completed') Completata: {{ $stato['processed'] }}/{{ $stato['total'] }} ({{ $stato['falliti'] }} fallite)
|
||||
@else Fallita: {{ $stato['error'] }}
|
||||
@endif
|
||||
</span>
|
||||
<span>{{ $percento }}%</span>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-gray-100 overflow-hidden">
|
||||
<div class="h-2 rounded-full transition-all duration-500 {{ $stato['status'] === 'failed' ? 'bg-red-500' : 'bg-indigo-500' }}"
|
||||
style="width: {{ $percento }}%"></div>
|
||||
</div>
|
||||
@if(!empty($stato['logs']))
|
||||
<div class="mt-3 max-h-48 overflow-y-auto rounded-lg bg-gray-900 p-3 font-mono text-xs text-gray-100 space-y-0.5">
|
||||
@foreach($stato['logs'] as $riga)
|
||||
<div>{{ $riga }}</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</ul>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
{{-- ── Storico versioni PDF ──────────────────────────────────────────── --}}
|
||||
<section class="tm-card">
|
||||
<div class="tm-card-testata">
|
||||
<h2 class="tm-card-titolo">Storico versioni PDF</h2>
|
||||
<p class="tm-card-nota">I PDF sostituiti da una piantina. Il ripristino è sempre reversibile.</p>
|
||||
</div>
|
||||
|
||||
{{-- ── 4. Storico versioni PDF ───────────────────────────────────── --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<h2 class="text-base font-semibold text-gray-900 mb-3">4 · Storico versioni PDF</h2>
|
||||
@if($versioni->isEmpty())
|
||||
<p class="text-sm text-gray-500">Nessun PDF è ancora stato sostituito.</p>
|
||||
<div class="tm-vuoto">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<p>Nessun PDF è ancora stato sostituito.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<div class="tm-tabella-scorre">
|
||||
<table class="tm-tabella table-cards">
|
||||
<thead>
|
||||
<tr class="text-left text-xs uppercase tracking-wider text-gray-500 border-b border-gray-200">
|
||||
<th class="py-2 pr-4">Territorio</th>
|
||||
<th class="py-2 pr-4">Sostituito il</th>
|
||||
<th class="py-2 pr-4">Origine</th>
|
||||
<th class="py-2"></th>
|
||||
<tr>
|
||||
<th scope="col">Territorio</th>
|
||||
<th scope="col">Sostituito il</th>
|
||||
<th scope="col">Origine</th>
|
||||
<th scope="col"><span class="tm-solo-lettori">Azioni</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tbody>
|
||||
@foreach($versioni as $versione)
|
||||
<tr wire:key="versione-{{ $versione->id }}">
|
||||
<td class="py-2 pr-4 font-medium text-gray-800">{{ $versione->territorio?->numero ?? '?' }}</td>
|
||||
<td class="py-2 pr-4 text-gray-600">{{ $versione->sostituito_il?->format('d/m/Y H:i') }}</td>
|
||||
<td class="py-2 pr-4 text-gray-600">{{ $versione->origine }}</td>
|
||||
<td class="py-2 text-right space-x-3 whitespace-nowrap">
|
||||
<a href="{{ asset('storage/' . $versione->pdf_path) }}" target="_blank" class="text-indigo-600 hover:underline text-xs">Apri</a>
|
||||
<button type="button" wire:click="ripristinaVersione({{ $versione->id }})"
|
||||
wire:confirm="Ripristinare questa versione per il territorio {{ $versione->territorio?->numero }}? Il PDF attuale finisce a sua volta nello storico."
|
||||
class="text-indigo-600 hover:underline text-xs">Ripristina</button>
|
||||
<button type="button" wire:click="eliminaVersione({{ $versione->id }})"
|
||||
wire:confirm="Eliminare definitivamente questa versione archiviata?"
|
||||
class="text-red-600 hover:underline text-xs">Elimina</button>
|
||||
<td data-label="Territorio" class="tm-tabella-forte">{{ $versione->territorio?->numero ?? '—' }}</td>
|
||||
<td data-label="Sostituito il">{{ $versione->sostituito_il?->format('d/m/Y H:i') }}</td>
|
||||
<td data-label="Origine">{{ $versione->origine }}</td>
|
||||
<td class="cell-actions">
|
||||
<a href="{{ asset('storage/' . $versione->pdf_path) }}" target="_blank" rel="noopener" class="tm-collegamento">Apri</a>
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm"
|
||||
wire:click="ripristinaVersione({{ $versione->id }})"
|
||||
wire:confirm="Ripristinare questa versione per il territorio {{ $versione->territorio?->numero }}? Il PDF attuale finisce a sua volta nello storico.">
|
||||
Ripristina
|
||||
</x-btn>
|
||||
<x-btn variante="danger" tono="tenue" dimensione="sm"
|
||||
wire:click="eliminaVersione({{ $versione->id }})"
|
||||
wire:confirm="Eliminare definitivamente questa versione archiviata?">
|
||||
Elimina
|
||||
</x-btn>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
@@ -223,7 +206,5 @@
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<script type="application/json" id="piantine-dati">@json($datiMappaIniziali)</script>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,8 @@ use App\Livewire\Settings\ZoneIndex;
|
||||
use App\Livewire\Settings\TipologieIndex;
|
||||
use App\Livewire\Settings\UsersIndex;
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Http\Controllers\Settings\PiantinaAnteprimaController;
|
||||
use App\Livewire\Piantine\PiantineEditor;
|
||||
use App\Livewire\Piantine\PiantineManager;
|
||||
use App\Livewire\Privacy;
|
||||
use App\Livewire\Profile;
|
||||
@@ -163,7 +165,13 @@ Route::middleware('auth')->group(function () {
|
||||
|
||||
// Piantine: sotto-app per generare mappe HD e PDF S-12 dei territori.
|
||||
// Il rendering vive nel container map-renderer; qui c'è solo l'editor.
|
||||
//
|
||||
// Due pagine, non una: la gestione (area, immagini, PDF, storico) sta
|
||||
// nel telaio normale dell'applicazione, il disegno ha una pagina sua a
|
||||
// schermo intero perché la mappa vuole tutto lo spazio che c'è.
|
||||
Route::get('piantine', PiantineManager::class)->name('piantine.index');
|
||||
Route::get('piantine/editor/{mappa?}', PiantineEditor::class)->name('piantine.editor');
|
||||
Route::get('piantine/{mappa}/anteprima', PiantinaAnteprimaController::class)->name('piantine.anteprima');
|
||||
Route::post('xml-exchange/convert-sql', [XmlExchangeUploadController::class, 'convertSqlToXml'])->name('xml.convert-sql');
|
||||
Route::post('xml-exchange/import-xml', [XmlExchangeUploadController::class, 'importXml'])->name('xml.import-xml');
|
||||
Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip');
|
||||
|
||||
@@ -67,6 +67,7 @@ class PagineApplicativeTest extends TestCase
|
||||
'tipologie.index',
|
||||
'xml.exchange',
|
||||
'piantine.index',
|
||||
'piantine.editor',
|
||||
'privacy',
|
||||
'assegnazioni.assegna',
|
||||
];
|
||||
|
||||
220
tests/Feature/PiantineEditorTest.php
Normal file
220
tests/Feature/PiantineEditorTest.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Piantine\PiantineEditor;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\Tipologia;
|
||||
use App\Models\User;
|
||||
use App\Models\Zona;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* L'editor a schermo intero: si disegna prima e si decide dopo a chi
|
||||
* appartiene il disegno — territorio compreso, che può nascere qui.
|
||||
*/
|
||||
class PiantineEditorTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const GEOMETRIA = [
|
||||
'type' => 'Polygon',
|
||||
'coordinates' => [[[13.35, 38.11], [13.37, 38.11], [13.37, 38.13], [13.35, 38.11]]],
|
||||
];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
Storage::fake('public');
|
||||
Storage::fake('local');
|
||||
}
|
||||
|
||||
public function test_la_pagina_e_riservata_all_amministratore(): void
|
||||
{
|
||||
Role::findOr('utente', fn () => Role::create(['name' => 'utente', 'guard_name' => 'web']));
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
$utente = User::create([
|
||||
'name' => 'Utente semplice',
|
||||
'email' => 'utente@example.test',
|
||||
'password' => 'password-di-prova',
|
||||
]);
|
||||
$utente->assignRole('utente');
|
||||
|
||||
$this->actingAs($utente)->get(route('piantine.editor'))->assertForbidden();
|
||||
$this->actingAs($this->admin())->get(route('piantine.editor'))->assertOk();
|
||||
}
|
||||
|
||||
public function test_una_piantina_si_salva_senza_nessun_territorio(): void
|
||||
{
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
||||
->set('nome', 'Zona porto — lato nord')
|
||||
->call('salva');
|
||||
|
||||
$mappa = TerritorioMappa::sole();
|
||||
$this->assertNull($mappa->territorio_id);
|
||||
$this->assertSame('Zona porto — lato nord', $mappa->nome);
|
||||
$this->assertSame(self::GEOMETRIA, $mappa->geometria());
|
||||
$this->assertSame('Zona porto — lato nord', $mappa->etichetta());
|
||||
}
|
||||
|
||||
public function test_il_territorio_si_crea_dalla_scheda_con_tutte_le_proprieta(): void
|
||||
{
|
||||
$zona = Zona::create(['nome' => 'Nord', 'attivo' => true]);
|
||||
$tipologia = Tipologia::create(['nome' => 'Residenziale', 'attivo' => true]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
||||
->set('territorioScelto', 'nuovo')
|
||||
->set('numero', '42')
|
||||
->set('zonaId', (string) $zona->id)
|
||||
->set('tipologiaId', (string) $tipologia->id)
|
||||
->set('confini', "Nord: via Roma\nSud: via Milano")
|
||||
->call('salva')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$territorio = Territorio::where('numero', '42')->sole();
|
||||
$this->assertTrue((bool) $territorio->attivo);
|
||||
$this->assertSame($zona->id, $territorio->zona_id);
|
||||
$this->assertSame($tipologia->id, $territorio->tipologia_id);
|
||||
$this->assertStringContainsString('via Roma', $territorio->confini);
|
||||
$this->assertSame($territorio->id, TerritorioMappa::sole()->territorio_id);
|
||||
}
|
||||
|
||||
public function test_la_scheda_aggiorna_un_territorio_gia_esistente(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
||||
->set('territorioScelto', (string) $territorio->id)
|
||||
->set('numero', '12')
|
||||
->set('confini', 'Nord: via Verdi')
|
||||
->call('salva')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertSame('Nord: via Verdi', $territorio->fresh()->confini);
|
||||
$this->assertSame(1, Territorio::count());
|
||||
}
|
||||
|
||||
public function test_zona_e_tipologia_si_creano_dall_editor_e_restano_selezionate(): void
|
||||
{
|
||||
$componente = Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->set('territorioScelto', 'nuovo')
|
||||
->set('nuovaZona', 'Litorale')
|
||||
->call('creaZona')
|
||||
->assertHasNoErrors()
|
||||
->set('nuovaTipologia', 'Commerciale')
|
||||
->call('creaTipologia')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$zona = Zona::where('nome', 'Litorale')->sole();
|
||||
$tipologia = Tipologia::where('nome', 'Commerciale')->sole();
|
||||
|
||||
$componente->assertSet('zonaId', (string) $zona->id);
|
||||
$componente->assertSet('tipologiaId', (string) $tipologia->id);
|
||||
}
|
||||
|
||||
public function test_una_zona_gia_esistente_non_viene_duplicata(): void
|
||||
{
|
||||
Zona::create(['nome' => 'Nord', 'attivo' => true]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->set('nuovaZona', 'Nord')
|
||||
->call('creaZona')
|
||||
->assertHasErrors('nuovaZona');
|
||||
|
||||
$this->assertSame(1, Zona::where('nome', 'Nord')->count());
|
||||
}
|
||||
|
||||
public function test_un_numero_gia_in_uso_non_crea_un_secondo_territorio(): void
|
||||
{
|
||||
Territorio::create(['numero' => '42', 'attivo' => true]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
||||
->set('territorioScelto', 'nuovo')
|
||||
->set('numero', '42')
|
||||
->call('salva')
|
||||
->assertHasErrors('numero');
|
||||
|
||||
$this->assertSame(1, Territorio::where('numero', '42')->count());
|
||||
$this->assertSame(0, TerritorioMappa::count());
|
||||
}
|
||||
|
||||
public function test_ridisegnare_sostituisce_il_poligono_senza_duplicare_la_piantina(): void
|
||||
{
|
||||
$mappa = TerritorioMappa::create([
|
||||
'nome' => 'Zona porto',
|
||||
'geojson' => '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}',
|
||||
]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
||||
->call('salva');
|
||||
|
||||
$this->assertSame(1, TerritorioMappa::count());
|
||||
$this->assertSame(self::GEOMETRIA, $mappa->fresh()->geometria());
|
||||
}
|
||||
|
||||
public function test_una_geometria_non_valida_viene_ignorata(): void
|
||||
{
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->dispatch('poligono-disegnato', geometry: ['type' => 'Point', 'coordinates' => [1, 2]])
|
||||
->call('salva');
|
||||
|
||||
$this->assertSame(0, TerritorioMappa::count());
|
||||
}
|
||||
|
||||
public function test_non_si_ruba_il_territorio_a_un_altra_piantina(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
TerritorioMappa::create([
|
||||
'territorio_id' => $territorio->id,
|
||||
'geojson' => json_encode(self::GEOMETRIA),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
||||
->set('territorioScelto', (string) $territorio->id)
|
||||
->set('numero', $territorio->numero)
|
||||
->call('salva');
|
||||
|
||||
// La seconda piantina non deve essere stata creata.
|
||||
$this->assertSame(1, TerritorioMappa::count());
|
||||
}
|
||||
|
||||
private function admin(): User
|
||||
{
|
||||
Role::findOr('amministratore', fn () => Role::create(['name' => 'amministratore', 'guard_name' => 'web']));
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
|
||||
|
||||
$utente = User::firstOrCreate(
|
||||
['email' => 'admin@example.test'],
|
||||
['name' => 'Amministratore', 'password' => 'password-di-prova'],
|
||||
);
|
||||
$utente->assignRole('amministratore');
|
||||
|
||||
return $utente;
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,13 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\RigeneraPiantine;
|
||||
use App\Livewire\Piantine\PiantineManager;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\User;
|
||||
use App\Services\Piantine\PiantineOperationState;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Role;
|
||||
@@ -19,8 +16,8 @@ use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* La pagina Piantine: accesso riservato, salvataggio dei poligoni disegnati,
|
||||
* caricamento del KML e avvio della rigenerazione massiva.
|
||||
* La pagina di gestione delle piantine: accesso riservato, area della città,
|
||||
* e il legame — che si può fare e disfare — fra una piantina e un territorio.
|
||||
*/
|
||||
class PiantineManagerTest extends TestCase
|
||||
{
|
||||
@@ -50,58 +47,6 @@ class PiantineManagerTest extends TestCase
|
||||
$this->actingAs($this->admin())->get(route('piantine.index'))->assertOk();
|
||||
}
|
||||
|
||||
public function test_il_poligono_disegnato_viene_salvato_sul_territorio(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
$geometry = [
|
||||
'type' => 'Polygon',
|
||||
'coordinates' => [[[13.35, 38.11], [13.37, 38.11], [13.37, 38.13], [13.35, 38.11]]],
|
||||
];
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->set('territorioSelezionato', $territorio->id)
|
||||
->dispatch('poligono-disegnato', geometry: $geometry)
|
||||
->call('salvaPoligono');
|
||||
|
||||
$mappa = TerritorioMappa::sole();
|
||||
$this->assertSame($territorio->id, $mappa->territorio_id);
|
||||
$this->assertSame($geometry, $mappa->geometria());
|
||||
}
|
||||
|
||||
public function test_ridisegnare_sostituisce_il_poligono_senza_duplicarlo(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
TerritorioMappa::create([
|
||||
'territorio_id' => $territorio->id,
|
||||
'geojson' => '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}',
|
||||
]);
|
||||
|
||||
$nuova = ['type' => 'Polygon', 'coordinates' => [[[5, 5], [6, 5], [6, 6], [5, 5]]]];
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->set('territorioSelezionato', $territorio->id)
|
||||
->dispatch('poligono-disegnato', geometry: $nuova)
|
||||
->call('salvaPoligono');
|
||||
|
||||
$this->assertSame(1, TerritorioMappa::count());
|
||||
$this->assertSame($nuova, TerritorioMappa::sole()->geometria());
|
||||
}
|
||||
|
||||
public function test_una_geometria_non_valida_viene_ignorata(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->set('territorioSelezionato', $territorio->id)
|
||||
->dispatch('poligono-disegnato', geometry: ['type' => 'Point', 'coordinates' => [1, 2]])
|
||||
->call('salvaPoligono');
|
||||
|
||||
$this->assertSame(0, TerritorioMappa::count());
|
||||
}
|
||||
|
||||
public function test_il_kml_caricato_diventa_l_area_attiva(): void
|
||||
{
|
||||
$kml = <<<'XML'
|
||||
@@ -132,41 +77,118 @@ XML;
|
||||
->assertHasErrors('kmlFile');
|
||||
}
|
||||
|
||||
public function test_rigenera_tutto_accoda_il_job_e_inizializza_lo_stato(): void
|
||||
public function test_una_piantina_si_collega_a_un_territorio(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$territorio = Territorio::create(['numero' => '3', 'attivo' => true]);
|
||||
TerritorioMappa::create([
|
||||
'territorio_id' => $territorio->id,
|
||||
'geojson' => '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}',
|
||||
]);
|
||||
|
||||
$componente = Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->call('rigeneraTutto', true);
|
||||
|
||||
Queue::assertPushed(RigeneraPiantine::class, fn ($job) => $job->anchePdf === true);
|
||||
|
||||
$state = app(PiantineOperationState::class);
|
||||
$operazioneId = $componente->get('operazioneId');
|
||||
$this->assertNotNull($operazioneId);
|
||||
$this->assertSame(1, $state->get($operazioneId)['total']);
|
||||
|
||||
// Con un'operazione aperta, un secondo avvio deve essere rifiutato.
|
||||
$componente->call('rigeneraTutto', false);
|
||||
Queue::assertPushed(RigeneraPiantine::class, 1);
|
||||
}
|
||||
|
||||
public function test_rigenera_tutto_senza_poligoni_non_accoda_nulla(): void
|
||||
{
|
||||
Queue::fake();
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
$mappa = $this->piantina(['nome' => 'Zona porto']);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->call('rigeneraTutto', false);
|
||||
->call('apriCollegamento', $mappa->id)
|
||||
->set('territorioDaCollegare', $territorio->id)
|
||||
->call('collega', $mappa->id);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
$this->assertSame($territorio->id, $mappa->fresh()->territorio_id);
|
||||
}
|
||||
|
||||
public function test_un_territorio_che_ha_gia_una_piantina_non_ne_accetta_una_seconda(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
$this->piantina(['territorio_id' => $territorio->id]);
|
||||
$seconda = $this->piantina(['nome' => 'Doppione']);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->call('apriCollegamento', $seconda->id)
|
||||
->set('territorioDaCollegare', $territorio->id)
|
||||
->call('collega', $seconda->id);
|
||||
|
||||
$this->assertNull($seconda->fresh()->territorio_id);
|
||||
}
|
||||
|
||||
public function test_scollegare_conserva_la_piantina_e_le_da_un_nome(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
$mappa = $this->piantina(['territorio_id' => $territorio->id]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->call('scollega', $mappa->id);
|
||||
|
||||
$mappa->refresh();
|
||||
$this->assertNull($mappa->territorio_id);
|
||||
$this->assertSame('Ex territorio 12', $mappa->nome);
|
||||
$this->assertDatabaseCount('territorio_mappe', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* La cascata di prima si portava via il disegno insieme al territorio — e
|
||||
* l'import XML svuota `territori` in blocco a ogni ripristino.
|
||||
*/
|
||||
public function test_cancellare_il_territorio_lascia_in_piedi_la_piantina(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
$mappa = $this->piantina(['territorio_id' => $territorio->id]);
|
||||
|
||||
$territorio->forceDelete();
|
||||
|
||||
$this->assertDatabaseCount('territorio_mappe', 1);
|
||||
$this->assertNull($mappa->fresh()->territorio_id);
|
||||
}
|
||||
|
||||
public function test_senza_territorio_il_pdf_non_si_genera(): void
|
||||
{
|
||||
$mappa = $this->piantina(['nome' => 'Zona porto']);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->call('generaPdf', $mappa->id);
|
||||
|
||||
$this->assertDatabaseCount('territorio_pdf_versioni', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* L'anteprima serve a guardare la cartolina PRIMA di mandarla in vigore:
|
||||
* se toccasse pdf_path o lo storico, non sarebbe un'anteprima.
|
||||
*/
|
||||
public function test_l_anteprima_mostra_il_pdf_senza_sostituire_niente(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '7', 'attivo' => true, 'confini' => 'Nord: via Roma']);
|
||||
$mappa = $this->piantina([
|
||||
'territorio_id' => $territorio->id,
|
||||
'image_path' => 'territori-mappe/piantina-prova.png',
|
||||
]);
|
||||
|
||||
$immagine = imagecreatetruecolor(80, 50);
|
||||
ob_start();
|
||||
imagepng($immagine);
|
||||
Storage::disk('public')->put($mappa->image_path, ob_get_clean());
|
||||
imagedestroy($immagine);
|
||||
|
||||
$risposta = $this->actingAs($this->admin())->get(route('piantine.anteprima', $mappa));
|
||||
|
||||
$risposta->assertOk();
|
||||
$risposta->assertHeader('Content-Type', 'application/pdf');
|
||||
$this->assertStringStartsWith('%PDF', $risposta->getContent());
|
||||
|
||||
$this->assertNull($territorio->fresh()->pdf_path);
|
||||
$this->assertSame(0, \App\Models\TerritorioPdfVersione::count());
|
||||
}
|
||||
|
||||
public function test_l_anteprima_di_una_piantina_scollegata_non_esiste(): void
|
||||
{
|
||||
$mappa = $this->piantina(['nome' => 'Scollegata']);
|
||||
|
||||
$this->actingAs($this->admin())
|
||||
->get(route('piantine.anteprima', $mappa))
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
private function piantina(array $valori = []): TerritorioMappa
|
||||
{
|
||||
return TerritorioMappa::create(array_merge([
|
||||
'geojson' => '{"type":"Polygon","coordinates":[[[13.35,38.11],[13.37,38.11],[13.37,38.13],[13.35,38.11]]]}',
|
||||
], $valori));
|
||||
}
|
||||
|
||||
private function admin(): User
|
||||
|
||||
@@ -69,6 +69,71 @@ class PiantinaPdfServiceTest extends TestCase
|
||||
$this->assertStringContainsString('/Count 2', $pdf);
|
||||
}
|
||||
|
||||
/**
|
||||
* La "Località" del modulo è il nome della zona: un dato solo, in un posto
|
||||
* solo, che si aggiorna quando la congregazione riorganizza il lavoro.
|
||||
*/
|
||||
public function test_la_localita_stampata_e_il_nome_della_zona(): void
|
||||
{
|
||||
[$territorio, $mappa] = $this->territorioConImmagine();
|
||||
$zona = \App\Models\Zona::create(['nome' => 'Palermo Montepellegrino', 'attivo' => true]);
|
||||
$territorio->update(['zona_id' => $zona->id]);
|
||||
|
||||
$testo = $this->testoDel($this->servizio()->genera($territorio->fresh(), $mappa));
|
||||
|
||||
$this->assertStringContainsString('Palermo Montepellegrino', $testo);
|
||||
}
|
||||
|
||||
public function test_senza_zona_l_intestazione_resta_vuota_senza_rompere_nulla(): void
|
||||
{
|
||||
[$territorio, $mappa] = $this->territorioConImmagine();
|
||||
|
||||
$pdf = $this->servizio()->genera($territorio, $mappa);
|
||||
|
||||
$this->assertStringStartsWith('%PDF', $pdf);
|
||||
}
|
||||
|
||||
/** I confini vanno sulla cartolina, non su una pagina a parte. */
|
||||
public function test_i_confini_finiscono_nel_pdf(): void
|
||||
{
|
||||
[$territorio, $mappa] = $this->territorioConImmagine();
|
||||
|
||||
$testo = $this->testoDel($this->servizio()->genera($territorio, $mappa));
|
||||
|
||||
$this->assertStringContainsString('Nord: via Roma', $testo);
|
||||
$this->assertStringContainsString('Sud: via Milano', $testo);
|
||||
}
|
||||
|
||||
public function test_senza_confini_il_pdf_lo_dichiara(): void
|
||||
{
|
||||
[$territorio, $mappa] = $this->territorioConImmagine();
|
||||
$territorio->update(['confini' => null]);
|
||||
|
||||
$testo = $this->testoDel($this->servizio()->genera($territorio->refresh(), $mappa));
|
||||
|
||||
$this->assertStringContainsString('Confini non ancora descritti', $testo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Il testo scritto da FPDF vive dentro i flussi compressi delle pagine:
|
||||
* per leggerlo vanno estratti e decompressi.
|
||||
*/
|
||||
private function testoDel(string $pdf): string
|
||||
{
|
||||
$testo = '';
|
||||
|
||||
if (preg_match_all('/stream\r?\n(.*?)\r?\nendstream/s', $pdf, $trovati)) {
|
||||
foreach ($trovati[1] as $flusso) {
|
||||
$decompresso = @gzuncompress($flusso);
|
||||
if ($decompresso !== false) {
|
||||
$testo .= $decompresso;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $testo;
|
||||
}
|
||||
|
||||
public function test_genera_senza_immagine_si_ferma_con_un_messaggio_chiaro(): void
|
||||
{
|
||||
[$territorio, $mappa] = $this->territorioConImmagine();
|
||||
|
||||
@@ -38,19 +38,56 @@ class PiantinaRendererTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_un_rendering_riuscito_salva_il_png_e_aggiorna_la_mappa(): void
|
||||
public function test_un_rendering_riuscito_salva_il_jpeg_e_aggiorna_la_mappa(): void
|
||||
{
|
||||
Http::fake(['*/render' => Http::response("\x89PNG\r\n\x1a\ncontenuto-finto")]);
|
||||
Http::fake(['*/render' => Http::response("\xFF\xD8\xFFcontenuto-finto")]);
|
||||
|
||||
$mappa = $this->mappa();
|
||||
$path = (new PiantinaRenderer())->render($mappa);
|
||||
|
||||
Storage::disk('public')->assertExists($path);
|
||||
$this->assertStringEndsWith('.jpg', $path);
|
||||
$mappa->refresh();
|
||||
$this->assertSame($path, $mappa->image_path);
|
||||
$this->assertNotNull($mappa->rendered_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formato e risoluzione non sono dettagli: la piantina finisce su una
|
||||
* cartolina da 146 mm, e sono i due numeri che decidono se il PDF pesa
|
||||
* mezzo megabyte o cinque.
|
||||
*/
|
||||
public function test_al_renderer_arrivano_formato_qualita_e_lato_massimo(): void
|
||||
{
|
||||
Http::fake(['*/render' => Http::response("\xFF\xD8\xFFcontenuto-finto")]);
|
||||
|
||||
(new PiantinaRenderer())->render($this->mappa());
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request['format'] === 'jpeg'
|
||||
&& $request['quality'] === config('piantine.qualita')
|
||||
&& $request['maxSide'] === config('piantine.max_side');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Passando da PNG a JPEG il vecchio file ha un'altra estensione: senza
|
||||
* rimozione esplicita resterebbe sul volume senza che niente lo referenzi.
|
||||
*/
|
||||
public function test_il_file_di_un_formato_precedente_viene_rimosso(): void
|
||||
{
|
||||
Http::fake(['*/render' => Http::response("\xFF\xD8\xFFcontenuto-finto")]);
|
||||
|
||||
$mappa = $this->mappa();
|
||||
$vecchio = 'territori-mappe/piantina-' . $mappa->id . '.png';
|
||||
Storage::disk('public')->put($vecchio, 'vecchio-png');
|
||||
$mappa->update(['image_path' => $vecchio]);
|
||||
|
||||
(new PiantinaRenderer())->render($mappa->refresh());
|
||||
|
||||
Storage::disk('public')->assertMissing($vecchio);
|
||||
}
|
||||
|
||||
public function test_un_errore_del_renderer_diventa_un_messaggio_leggibile(): void
|
||||
{
|
||||
Http::fake(['*/render' => Http::response(['error' => 'area troppo estesa'], 413)]);
|
||||
@@ -61,7 +98,7 @@ class PiantinaRendererTest extends TestCase
|
||||
(new PiantinaRenderer())->render($this->mappa());
|
||||
}
|
||||
|
||||
public function test_una_risposta_che_non_e_un_png_viene_rifiutata(): void
|
||||
public function test_una_risposta_che_non_e_un_immagine_viene_rifiutata(): void
|
||||
{
|
||||
Http::fake(['*/render' => Http::response('<html>proxy error</html>')]);
|
||||
|
||||
|
||||
@@ -100,18 +100,4 @@ XML;
|
||||
$this->assertSame('Polygon', $area->area()['features'][0]['geometry']['type']);
|
||||
}
|
||||
|
||||
public function test_la_localita_esplicita_vince_sul_nome_congregazione(): void
|
||||
{
|
||||
\App\Models\Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
|
||||
|
||||
$area = new PiantineArea();
|
||||
$this->assertSame('Congregazione di prova', $area->localita());
|
||||
|
||||
$area->impostaLocalita('Palermo Nord');
|
||||
$this->assertSame('Palermo Nord', $area->localita());
|
||||
|
||||
// Svuotarla riporta al nome della congregazione.
|
||||
$area->impostaLocalita(' ');
|
||||
$this->assertSame('Congregazione di prova', $area->localita());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user