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:
2026-08-28 16:41:22 +02:00
parent 7e4c8aa9b8
commit 9c37c1ad7b
30 changed files with 3032 additions and 982 deletions

View File

@@ -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

View File

@@ -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',
]);
}
}

View File

@@ -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.'
);
}
}

View 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',
]);
}
}

View File

@@ -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',
]);
}

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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");
}
}

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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),
];

View File

@@ -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();
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -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
View File

@@ -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",

View File

@@ -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

View File

@@ -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: '&copy; <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') {

View File

@@ -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)"

View 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">&times;</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">&times;</button>
</div>
@endif
</div>
@livewireScripts
</body>
</html>

View 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">&times;</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: …&#10;Est: …&#10;Sud: …&#10;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>

View File

@@ -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>

View File

@@ -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');

View File

@@ -67,6 +67,7 @@ class PagineApplicativeTest extends TestCase
'tipologie.index',
'xml.exchange',
'piantine.index',
'piantine.editor',
'privacy',
'assegnazioni.assegna',
];

View 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;
}
}

View File

@@ -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

View File

@@ -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();

View File

@@ -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>')]);

View File

@@ -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());
}
}