From 9c37c1ad7be32a45731745417050b634621a9fb5 Mon Sep 17 00:00:00 2001 From: Francesco Picone Date: Fri, 28 Aug 2026 16:41:22 +0200 Subject: [PATCH] Piantine staccate dai territori, editor a schermo intero, restyle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- HANDOFF.md | 160 ++- .../Settings/PiantinaAnteprimaController.php | 40 + app/Jobs/RigeneraPiantine.php | 99 -- app/Livewire/Piantine/PiantineEditor.php | 401 +++++++ app/Livewire/Piantine/PiantineManager.php | 258 ++--- app/Models/TerritorioMappa.php | 31 + app/Services/Piantine/PiantinaPdfService.php | 145 ++- app/Services/Piantine/PiantinaRenderer.php | 50 +- app/Services/Piantine/PiantineArea.php | 33 +- .../Piantine/PiantineOperationState.php | 152 --- config/piantine.php | 41 +- ..._28_000010_rendi_piantine_indipendenti.php | 67 ++ ...026_08_28_000020_localita_per_piantina.php | 29 + .../2026_08_28_000030_localita_dalla_zona.php | 29 + docker/renderer/server.js | 153 ++- package-lock.json | 14 +- package.json | 3 + resources/css/app.css | 1027 +++++++++++++++-- resources/js/piantine.js | 77 +- .../views/components/layouts/app.blade.php | 6 +- .../views/components/layouts/editor.blade.php | 55 + .../piantine/piantine-editor.blade.php | 204 ++++ .../piantine/piantine-manager.blade.php | 393 +++---- routes/web.php | 8 + tests/Feature/PagineApplicativeTest.php | 1 + tests/Feature/PiantineEditorTest.php | 220 ++++ tests/Feature/PiantineManagerTest.php | 196 ++-- tests/Unit/PiantinaPdfServiceTest.php | 65 ++ tests/Unit/PiantinaRendererTest.php | 43 +- tests/Unit/PiantineAreaTest.php | 14 - 30 files changed, 3032 insertions(+), 982 deletions(-) create mode 100644 app/Http/Controllers/Settings/PiantinaAnteprimaController.php delete mode 100644 app/Jobs/RigeneraPiantine.php create mode 100644 app/Livewire/Piantine/PiantineEditor.php delete mode 100644 app/Services/Piantine/PiantineOperationState.php create mode 100644 database/migrations/2026_08_28_000010_rendi_piantine_indipendenti.php create mode 100644 database/migrations/2026_08_28_000020_localita_per_piantina.php create mode 100644 database/migrations/2026_08_28_000030_localita_dalla_zona.php create mode 100644 resources/views/components/layouts/editor.blade.php create mode 100644 resources/views/livewire/piantine/piantine-editor.blade.php create mode 100644 tests/Feature/PiantineEditorTest.php diff --git a/HANDOFF.md b/HANDOFF.md index 60186b7..a4785d3 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non può dare: perché le cose sono come sono, e cosa resta aperto. -Ultimo aggiornamento: **31 luglio 2026**. +Ultimo aggiornamento: **28 agosto 2026**. --- @@ -22,7 +22,7 @@ redeploy, login, tutte le pagine, code e scheduler. | Sviluppo locale | ✅ stesso compose, profilo `dev` | | Backup / ripristino / azzeramento | ✅ verificati end-to-end in locale | | Ripristino da browser in linea | ⚠️ **limitato a 100 MB da Cloudflare** — vedi [Cosa resta aperto](#cosa-resta-aperto) | -| Test automatici | ⚠️ **impalcatura pronta, 57 test** — copertura ancora parziale | +| Test automatici | ⚠️ **impalcatura pronta, 294 test** — copertura ancora parziale | **Punto in cui ci si è fermati (30/07/2026).** Su `develop` è pubblicato tutto: backup completo `.zip`, ripristino, azzeramento, cambio password forzato, barre @@ -39,6 +39,18 @@ architetturali](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi) Al primo deploy il compose costruisce anche l'immagine del renderer; non servono variabili nuove in Dokploy. +**Il 28/08 la sotto-app è stata rifatta e l'interfaccia ripassata.** Le piantine +si sono staccate dai territori (una piantina vive anche scollegata, il territorio +si crea dall'editor), il disegno è passato su una pagina a schermo intero, la +cartolina S-12 è stata reimpaginata (confini sulla prima pagina, piantina al 98% +sulla seconda) e la rigenerazione massiva è stata rimossa. In più il sistema +visivo è stato ripassato su tutta l'applicazione, non solo qui: vedi [Il sistema +visivo sta nei token](#il-sistema-visivo-sta-nei-token-non-nelle-viste). + +Nello stesso giro è emerso e corretto un guasto d'ambiente che bloccava **tutti** +gli upload in sviluppo: vedi [`HTTP_HOST` a nginx deve conservare la +porta](#http_host-a-nginx-deve-conservare-la-porta). + Il 30/07 sono state fatte due cose. **La suite di test, che prima non esisteva**: 79 test eseguibili con un comando, @@ -166,13 +178,13 @@ password `secret`. ### Le piantine sono una sotto-app: renderer esterno, agganci minimi -La pagina **Piantine** (`/piantine`, solo amministratore) genera le mappe dei -territori: si carica il KML dell'area della città, si disegnano i poligoni su -OpenStreetMap (Leaflet), e da ogni poligono si ottiene un'immagine ad alta -definizione e — a scelta — il PDF sul modulo ufficiale S-12 -(`resources/pdf/S-12_I.pdf`, importato con FPDI) che sostituisce quello del +Le **Piantine** (solo amministratore) generano le mappe dei territori. Si carica +il KML dell'area della città, si disegnano i confini su OpenStreetMap (Leaflet) +nell'editor a schermo intero, e da ogni disegno si ottiene un'immagine ad alta +definizione e — a scelta — la cartolina sul modulo ufficiale S-12 +(`resources/pdf/S-12_I.pdf`, importato con FPDI) che sostituisce il PDF del territorio. Il PDF precedente finisce nella tabella `territorio_pdf_versioni` -ed è ripristinabile dalla stessa pagina. +ed è ripristinabile dalla pagina di gestione. Il rendering vero e proprio **non sta nell'applicazione**: lo fa il container `map-renderer` (`docker/renderer/`, Node + sharp), che scarica le tessere OSM @@ -192,9 +204,104 @@ Le geometrie vivono nella tabella separata `territorio_mappe`, l'area KML in `storage/app/piantine/`: rimuovere la sotto-app significa togliere questi pezzi, non disfare il gestionale. -La rigenerazione massiva (`Jobs/RigeneraPiantine`) usa lo stesso schema di -avanzamento di backup e import PDF (`PiantineOperationState`, cache + poll -della pagina, un'operazione alla volta). +**Il disegno viene prima del numero.** Una piantina non ha bisogno di un +territorio: `territorio_mappe.territorio_id` è nullable e il collegamento si fa +(e si disfa) quando si vuole, con `nome` come etichetta finché manca. Si taglia +l'area guardando le strade e solo dopo si decide di chi è — e se quel territorio +non esiste, lo si crea dall'editor col solo numero. La chiave esterna è +`nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare +`territori` come fa ogni import XML, non porta più via i disegni. + +**Due pagine, non una.** `/piantine` tiene area, generazione, collegamenti e +storico; `/piantine/editor` è l'editor a schermo intero, con un telaio suo +(`layouts/editor`) senza barra laterale. Tagliare un confine su una mappa larga +un terzo di schermo vuol dire sbagliarlo, e quel disegno finisce stampato. + +La rigenerazione massiva è stata tolta: job, stato in cache e interfaccia. Si +rigenera un territorio alla volta dall'elenco. + +**La cartolina e la piantina sono due pagine distinte.** Il riquadro dell'S-12 è +largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi +pagina 1 è il modulo con Località, numero e i **confini scritti** nello spazio +centrale (il corpo del testo si restringe da solo finché ci sta), pagina 2 è la +piantina sola al 98% della pagina. + +**La "Località" del modulo è il nome della zona.** Era un campo globale, poi un +campo della piantina, e in entrambi i casi era un doppione: la zona esiste già, +dice la stessa cosa («Palermo Montepellegrino») ed è quella che si aggiorna +quando la congregazione riorganizza il lavoro. Due posti dove scrivere lo stesso +nome divergono prima o poi, e sulla carta finisce quello sbagliato. L'elenco +marca i territori collegati senza zona: senza, l'intestazione della cartolina +esce vuota e ce se ne accorge in stampa. + +**L'anteprima non tocca niente.** `piantine.anteprima` compone il PDF al volo e +lo serve `inline`: non aggiorna `pdf_path`, non archivia versioni, non lascia +file. «Genera PDF» manda il documento in vigore, e scoprire un confine sbagliato +dopo significa trovarselo già nello storico. + +**L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte +che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`): + +- Il renderer compone alle tessere dello zoom più alto che entri in + `max_side × 2` e poi riduce con Lanczos. Le etichette OSM sono disegnate + dentro la tessera a dimensione fissa: salire di uno zoom le rende più + definite, e ridurre dopo porta quella nitidezza dentro l'immagine finale. +- Chiede al renderer le **proporzioni della pagina** (`aspetto`): un territorio + quasi quadrato su una cartolina orizzontale, tenendo le sue proporzioni, + lascerebbe due fasce bianche larghe un quinto di foglio. Allargando il + ritaglio si mostrano più dintorni invece di carta vuota. +- JPEG a qualità 86 invece di PNG: 0,9 MB invece di 5 per territorio. Con 274 + territori è la differenza fra qualche centinaio di MB e diversi GB. + +L'attribuzione OSM si dimensiona sull'immagine finale, non su una misura fissa: +22 px su 3400 sarebbero meno di un millimetro stampato, cioè presenti nel file +ma invisibili a chi tiene la cartolina — e la ODbL la vuole leggibile. Per lo +stesso motivo il tratto del confine è espresso in pixel dell'immagine finale e +riportato alla scala di composizione: deve dire dove passa il confine senza +cancellare la via che ci corre sotto, che è proprio quella che serve leggere. + +### Il sistema visivo sta nei token, non nelle viste + +`resources/css/app.css` **ridefinisce le scale `gray` e `indigo` di Tailwind** +dentro `@theme`. È la cosa che sorprende di più aprendo il file, ed è +deliberata: le trentasette viste sono scritte con le utility (`text-gray-600`, +`bg-indigo-50`), e cambiare la scala le riallinea tutte insieme. L'alternativa +— riscrivere le classi vista per vista — a ogni file dimenticato lascia in giro +il grigio di prima accanto a quello nuovo, e nessuno se ne accorge finché non +apre proprio quella pagina. + +Da qui tre conseguenze che vanno sapute prima di metterci mano: + +- **I grigi sono caldi.** Quelli di serie di Tailwind tirano al blu e, sommati a + un accento indaco, davano all'interfaccia una dominante fredda. Questi elenchi + diventano moduli di carta che passano di mano: un fondo caldo li avvicina a + quello che sono. Non mescolare grigi freddi qui dentro. +- **L'accento è uno.** Indaco = azione, verde = fatto, ambra = in sospeso, + rosso = distrugge dati. Se una cosa non rientra in queste quattro, è grigia. + Prima le tinte erano cinque e decorative. +- **Il carattere è servito dall'applicazione** (`@fontsource-variable/figtree`, + dipendenza npm), non da Google Fonts. Un gestionale che tiene nomi e indirizzi + di persone non deve chiamare un dominio di terze parti a ogni pagina caricata, + e così funziona anche senza rete. + +I componenti `tm-*` (pulsanti, console, card, campi, elenchi, editor) stanno +tutti qui e **fuori da `@layer`**, quindi vincono sulle utility: un pulsante non +si può ritoccare a metà con le classi. Chi aggiunge una schermata usa questi, +non catene di utility nuove. + +### `HTTP_HOST` a nginx deve conservare la porta + +`docker/nginx/default.conf` rimette `fastcgi_param HTTP_HOST $http_host;` dopo +l'`include fastcgi_params`. Non e' ridondanza: dalla 1.26 il `fastcgi_params` di +sistema usa `$host`, che la porta la butta via. Con `APP_URL` su porta non +standard — cioe' lo sviluppo locale su `:8090` — Laravel ricostruisce l'URL +della richiesta senza porta mentre `URL::forceRootUrl()` genera i link con, e +ogni URL firmato non valida piu': **tutti** gli upload di file rispondono 401, +perche' l'endpoint di Livewire aborta sulla firma. In produzione `APP_URL` non +ha porta e le due forme coincidono, quindi il guasto si vede solo in locale. +Inoltrare l'header grezzo e' sicuro perche' `trustHosts()` in `bootstrap/app.php` +rifiuta gia' gli Host che non siano quello di `APP_URL`, `localhost` o +`127.0.0.1`. ### Modifica/cancellazione delle assegnazioni: solo amministratore @@ -918,7 +1025,7 @@ blocchi che impedivano di scrivere il primo test sono stati rimossi: stage docker compose --profile test run --build --rm test ``` -**Coperto oggi — 233 test, tutti verdi:** +**Coperto oggi — 294 test, tutti verdi:** | File | Cosa protegge | |------|---------------| @@ -929,6 +1036,10 @@ docker compose --profile test run --build --rm test | `tests/Feature/PaginaBackupImportTest.php` | La pagina servita per intero: permessi, le cinque zone con la loro ancora **e nel loro ordine**, la console unica, l'assenza dei vecchi riquadri di log, che nessun tag `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', + ]); + } +} diff --git a/app/Jobs/RigeneraPiantine.php b/app/Jobs/RigeneraPiantine.php deleted file mode 100644 index 9b5eab5..0000000 --- a/app/Jobs/RigeneraPiantine.php +++ /dev/null @@ -1,99 +0,0 @@ -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.' - ); - } -} diff --git a/app/Livewire/Piantine/PiantineEditor.php b/app/Livewire/Piantine/PiantineEditor.php new file mode 100644 index 0000000..95a70d0 --- /dev/null +++ b/app/Livewire/Piantine/PiantineEditor.php @@ -0,0 +1,401 @@ +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', + ]); + } +} diff --git a/app/Livewire/Piantine/PiantineManager.php b/app/Livewire/Piantine/PiantineManager.php index d2eaa35..a85f3f2 100644 --- a/app/Livewire/Piantine/PiantineManager.php +++ b/app/Livewire/Piantine/PiantineManager.php @@ -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', ]); } diff --git a/app/Models/TerritorioMappa.php b/app/Models/TerritorioMappa.php index ec30d5d..76407fb 100644 --- a/app/Models/TerritorioMappa.php +++ b/app/Models/TerritorioMappa.php @@ -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; + } } diff --git a/app/Services/Piantine/PiantinaPdfService.php b/app/Services/Piantine/PiantinaPdfService.php index a9a1f1f..d8fb9eb 100644 --- a/app/Services/Piantine/PiantinaPdfService.php +++ b/app/Services/Piantine/PiantinaPdfService.php @@ -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. diff --git a/app/Services/Piantine/PiantinaRenderer.php b/app/Services/Piantine/PiantinaRenderer.php index 38a9c4b..9d8cc8a 100644 --- a/app/Services/Piantine/PiantinaRenderer.php +++ b/app/Services/Piantine/PiantinaRenderer.php @@ -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"); + } } diff --git a/app/Services/Piantine/PiantineArea.php b/app/Services/Piantine/PiantineArea.php index be81148..8df5020 100644 --- a/app/Services/Piantine/PiantineArea.php +++ b/app/Services/Piantine/PiantineArea.php @@ -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 diff --git a/app/Services/Piantine/PiantineOperationState.php b/app/Services/Piantine/PiantineOperationState.php deleted file mode 100644 index cae2634..0000000 --- a/app/Services/Piantine/PiantineOperationState.php +++ /dev/null @@ -1,152 +0,0 @@ - $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; - } -} diff --git a/config/piantine.php b/config/piantine.php index 00f13af..da6889a 100644 --- a/config/piantine.php +++ b/config/piantine.php @@ -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), ]; diff --git a/database/migrations/2026_08_28_000010_rendi_piantine_indipendenti.php b/database/migrations/2026_08_28_000010_rendi_piantine_indipendenti.php new file mode 100644 index 0000000..8fd8633 --- /dev/null +++ b/database/migrations/2026_08_28_000010_rendi_piantine_indipendenti.php @@ -0,0 +1,67 @@ +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(); + }); + } +}; diff --git a/database/migrations/2026_08_28_000020_localita_per_piantina.php b/database/migrations/2026_08_28_000020_localita_per_piantina.php new file mode 100644 index 0000000..57f74c2 --- /dev/null +++ b/database/migrations/2026_08_28_000020_localita_per_piantina.php @@ -0,0 +1,29 @@ +string('localita')->nullable()->after('nome'); + }); + } + + public function down(): void + { + Schema::table('territorio_mappe', function (Blueprint $table) { + $table->dropColumn('localita'); + }); + } +}; diff --git a/database/migrations/2026_08_28_000030_localita_dalla_zona.php b/database/migrations/2026_08_28_000030_localita_dalla_zona.php new file mode 100644 index 0000000..db730aa --- /dev/null +++ b/database/migrations/2026_08_28_000030_localita_dalla_zona.php @@ -0,0 +1,29 @@ +dropColumn('localita'); + }); + } + + public function down(): void + { + Schema::table('territorio_mappe', function (Blueprint $table) { + $table->string('localita')->nullable()->after('nome'); + }); + } +}; diff --git a/docker/renderer/server.js b/docker/renderer/server.js index 96abc55..4771bcc 100644 --- a/docker/renderer/server.js +++ b/docker/renderer/server.js @@ -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 = ` - + `; // 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 = ` - - ${label} + 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 = ` + + ${label} `; - 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; } diff --git a/package-lock.json b/package-lock.json index 0cf0987..f2dc436 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0faef6a..37df51e 100644 --- a/package.json +++ b/package.json @@ -15,5 +15,8 @@ "leaflet-draw": "^1.0.4", "tailwindcss": "^4.0", "vite": "^6.0" + }, + "dependencies": { + "@fontsource-variable/figtree": "^5.3.0" } } diff --git a/resources/css/app.css b/resources/css/app.css index 555a671..455c3d7 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,6 +1,127 @@ @import "tailwindcss"; -/* ─── TerManager2 Custom UI ─── */ +/* Carattere servito dall'applicazione, non da un CDN esterno: questo + gestionale tiene nomi e indirizzi di persone, e una richiesta a un dominio + di terze parti a ogni pagina caricata sarebbe un dato in uscita che nessuno + ha chiesto. Il pacchetto e' una dipendenza npm, quindi finisce nel bundle e + funziona anche senza rete. */ +@import "@fontsource-variable/figtree"; + +/* ═══════════════════════════════════════════════════════════════════════════ + TerManager2 — sistema visivo + + I token stanno qui e in nessun altro posto. Ridefinire le scale `gray` e + `indigo` di Tailwind e' deliberato: le trentasette viste dell'applicazione + sono scritte con le utility (`text-gray-600`, `bg-indigo-50`), e cambiare + la scala le riallinea tutte insieme invece di costringere a riscriverle una + per una — con il rischio, a ogni vista dimenticata, di lasciare in giro il + grigio di prima accanto a quello nuovo. + + Due scelte di fondo: + + I grigi sono caldi. Il grigio di serie di Tailwind tira al blu e, sommato + a un accento indaco, dava a tutta l'interfaccia una + dominante fredda da pannello di controllo. Su carta + questi elenchi diventano moduli che passano di mano: + un fondo caldo li avvicina a quello che sono. + + L'accento e' uno. Prima ce n'erano cinque (indaco, verde, blu, rosso, + ambra) usati come decorazione. Ora il colore vuol + dire qualcosa: indaco = azione, verde = fatto, + ambra = in sospeso, rosso = distrugge dati. Se non + rientra in queste quattro, e' grigio. + ═══════════════════════════════════════════════════════════════════════════ */ + +@theme { + --font-sans: "Figtree Variable", ui-sans-serif, system-ui, -apple-system, + "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + + /* Grigi caldi: una sola famiglia, nessuna mescolanza caldo/freddo. */ + --color-gray-50: #f7f6f4; + --color-gray-100: #efedea; + --color-gray-200: #e3e0da; + --color-gray-300: #cfcbc2; + --color-gray-400: #a8a29a; + --color-gray-500: #837d74; + --color-gray-600: #625c54; + --color-gray-700: #4a453f; + --color-gray-800: #302c28; + --color-gray-900: #1c1a17; + --color-gray-950: #12100e; + + /* Indaco smorzato: stessa identita' di prima, meno stridente. */ + --color-indigo-50: #eeeef9; + --color-indigo-100: #dedef3; + --color-indigo-200: #c3c2e8; + --color-indigo-300: #a09ed8; + --color-indigo-400: #7c79c5; + --color-indigo-500: #605cb2; + --color-indigo-600: #4a45a8; + --color-indigo-700: #3b3789; + --color-indigo-800: #2f2c6c; + --color-indigo-900: #262350; + --color-indigo-950: #16142f; +} + +:root { + --tm-fondo: #f7f6f4; + --tm-superficie: #ffffff; + --tm-superficie-alt: #faf9f7; + --tm-bordo: #e3e0da; + --tm-bordo-forte: #cfcbc2; + + --tm-testo: #1c1a17; + --tm-testo-medio: #4a453f; + --tm-testo-tenue: #837d74; + + --tm-accento: #4a45a8; + --tm-accento-scuro: #3b3789; + --tm-accento-tenue: #eeeef9; + + --tm-ok: #2f6f4e; + --tm-ok-tenue: #e8f2ec; + --tm-attesa: #a06a22; + --tm-attesa-tenue: #fbf1e2; + --tm-pericolo: #b3352c; + --tm-pericolo-tenue: #fbeceb; + + /* Le ombre portano la tinta del fondo invece del nero puro: su una + superficie calda un'ombra neutra sporca di grigio. */ + --tm-ombra-1: 0 1px 2px rgba(43, 37, 28, .05); + --tm-ombra-2: 0 2px 4px rgba(43, 37, 28, .05), 0 6px 16px -8px rgba(43, 37, 28, .10); + --tm-ombra-3: 0 8px 28px -10px rgba(43, 37, 28, .18); + + --tm-raggio: 14px; + --tm-raggio-interno: 10px; +} + +html.tm-app, +body { + background: var(--tm-fondo); + color: var(--tm-testo); +} + +body { + font-family: var(--font-sans); + font-synthesis-weight: none; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* I titoli devono avere peso e stare stretti: alla dimensione di prima, con + spaziatura di serie, si leggevano come testo qualsiasi in grassetto. */ +h1, h2, h3 { + letter-spacing: -.018em; + text-wrap: balance; +} + +/* Le cifre incolonnate vogliono larghezza fissa, altrimenti in un elenco di + date e numeri di territorio le colonne ballano riga per riga. */ +table, .tm-tabella, .tm-cifre { + font-variant-numeric: tabular-nums; +} + +p { text-wrap: pretty; } /* Alpine nasconde con x-cloak solo se il foglio di stile lo dice: la regola non arriva col pacchetto. Senza, gli elementi marcati x-cloak (le barre di @@ -8,71 +129,539 @@ Alpine parta. */ [x-cloak] { display: none !important; } -/* Card hover lift effect */ +html { scroll-behavior: smooth; } + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } +} + +/* Chi naviga da tastiera deve poter saltare la barra laterale invece di + percorrerla a ogni pagina. */ +.tm-salta { + position: absolute; + left: -9999px; + z-index: 100; + padding: 10px 16px; + border-radius: var(--tm-raggio-interno); + background: var(--tm-accento); + color: #fff; + font-size: 14px; + font-weight: 600; +} +.tm-salta:focus { + left: 12px; + top: 12px; +} + +.tm-solo-lettori { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +/* ─── Superfici ──────────────────────────────────────────────────────────── + Una card esiste quando l'elevazione dice qualcosa. Il bordo fa il lavoro + principale, l'ombra e' appena percepibile: bordo + ombra marcata + fondo + bianco insieme e' il riquadro di serie che si vede ovunque. */ .card-hover { - transition: transform 0.15s ease, box-shadow 0.15s ease; + transition: transform .18s ease, box-shadow .18s ease; } .card-hover:hover { transform: translateY(-2px); - box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1), 0 4px 10px -5px rgba(0, 0, 0, 0.04); + box-shadow: var(--tm-ombra-3); } -/* Colored left accent border for stat cards */ -.card-accent-indigo { border-left: 4px solid #6366f1; } -.card-accent-green { border-left: 4px solid #22c55e; } -.card-accent-blue { border-left: 4px solid #3b82f6; } -.card-accent-red { border-left: 4px solid #ef4444; } -.card-accent-amber { border-left: 4px solid #f59e0b; } +/* Bordo d'accento sulle schede di riepilogo: quattro significati, non cinque + tinte decorative. */ +.card-accent-indigo { border-left: 3px solid var(--tm-accento); } +.card-accent-green { border-left: 3px solid var(--tm-ok); } +.card-accent-blue { border-left: 3px solid var(--tm-accento); } +.card-accent-red { border-left: 3px solid var(--tm-pericolo); } +.card-accent-amber { border-left: 3px solid var(--tm-attesa); } -/* Table zebra striping */ .table-striped tbody tr:nth-child(even) { - background-color: #f9fafb; + background-color: var(--tm-superficie-alt); } -/* Gradient header */ +/* La testata era un gradiente indaco a 45 gradi — la firma piu' riconoscibile + di un'interfaccia generata. Adesso e' una tinta piena e profonda, con un solo + velo verticale appena accennato per non essere piatta come un francobollo. */ .header-gradient { - background: linear-gradient(135deg, #4338ca 0%, #6366f1 50%, #818cf8 100%); + background: linear-gradient(180deg, #333072 0%, #2b2860 100%); } -/* Sidebar active state */ .sidebar-active { - background: linear-gradient(90deg, #eef2ff, #e0e7ff); - border-right: 3px solid #6366f1; - color: #4338ca; + background: var(--tm-accento-tenue); + border-right: 3px solid var(--tm-accento); + color: var(--tm-accento-scuro); font-weight: 600; } -/* Smooth page transitions */ .page-enter { - animation: fadeSlideIn 0.25s ease-out; + animation: fadeSlideIn .28s cubic-bezier(.22, .61, .36, 1); } @keyframes fadeSlideIn { - from { opacity: 0; transform: translateY(8px); } + from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } -/* Empty state styling */ .empty-state { display: flex; flex-direction: column; align-items: center; - gap: 8px; - padding: 2rem 1rem; - color: #9ca3af; + gap: 10px; + padding: 2.5rem 1rem; + color: var(--tm-testo-tenue); } .empty-state svg { - width: 48px; - height: 48px; - opacity: 0.4; + width: 44px; + height: 44px; + opacity: .35; } -/* Badge pulse for active items */ .badge-pulse { animation: pulse 2s infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } - 50% { opacity: 0.7; } + 50% { opacity: .7; } +} + +/* ─── Impaginazione ───────────────────────────────────────────────────────── */ + +.tm-pagina-testata { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 28px; +} +.tm-pagina-titolo { + margin: 0; + font-size: clamp(1.5rem, 1.2rem + 1vw, 1.9rem); + font-weight: 600; + line-height: 1.15; + color: var(--tm-testo); +} +.tm-pagina-nota { + margin: 6px 0 0; + max-width: 62ch; + font-size: 14px; + line-height: 1.6; + color: var(--tm-testo-medio); +} + +.tm-card { + background: var(--tm-superficie); + border: 1px solid var(--tm-bordo); + border-radius: var(--tm-raggio); + box-shadow: var(--tm-ombra-1); + /* Il basso ottico vuole un filo piu' del sopra, altrimenti il blocco + sembra appoggiato storto. */ + padding: 20px 22px 24px; + margin-bottom: 20px; +} +.tm-card-testata { + margin-bottom: 18px; +} + +/* Scheda che apre la pagina: è il presupposto di tutto quello che segue, e + deve leggersi come tale prima ancora di aver letto il titolo. Il rilievo + glielo danno la barra d'accento e il fondo appena caldo, non un colore + acceso — che a fianco di un avviso vero ne ruberebbe la voce. */ +.tm-card-primaria { + position: relative; + padding-left: 26px; + background: linear-gradient(180deg, var(--tm-superficie-alt) 0%, var(--tm-superficie) 60%); + box-shadow: var(--tm-ombra-2); +} +.tm-card-primaria::before { + content: ""; + position: absolute; + left: 0; + top: -1px; + bottom: -1px; + width: 4px; + border-radius: var(--tm-raggio) 0 0 var(--tm-raggio); + background: var(--tm-accento); +} +/* Finché il KML manca, l'accento diventa ambra: è un lavoro da fare. */ +.tm-card-manca::before { background: var(--tm-attesa); } +.tm-card-manca { + border-color: #e8d3ae; + background: linear-gradient(180deg, var(--tm-attesa-tenue) 0%, var(--tm-superficie) 65%); +} + +.tm-card-testata-icona { + display: flex; + align-items: flex-start; + gap: 14px; +} +.tm-card-icona { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 40px; + height: 40px; + border-radius: var(--tm-raggio-interno); + border: 1px solid var(--tm-bordo); + background: var(--tm-superficie); + color: var(--tm-accento); +} +.tm-card-icona svg { width: 21px; height: 21px; } +.tm-card-manca .tm-card-icona { color: var(--tm-attesa); border-color: #e8d3ae; } + +/* Chiosa accanto all'etichetta di un campo: spiega dove finisce quel valore + senza spingere la spiegazione sotto, dove verrebbe letta dopo la scelta. */ +.tm-campo-nota { + margin-left: 6px; + font-size: 11.5px; + font-weight: 500; + color: var(--tm-testo-tenue); +} +.tm-card-titolo { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + font-size: 1.02rem; + font-weight: 600; + color: var(--tm-testo); +} +.tm-card-nota { + margin: 5px 0 0; + max-width: 68ch; + font-size: 13px; + line-height: 1.6; + color: var(--tm-testo-tenue); +} +.tm-conteggio { + display: inline-flex; + align-items: center; + min-width: 22px; + height: 22px; + padding: 0 7px; + border-radius: 999px; + background: var(--tm-superficie-alt); + border: 1px solid var(--tm-bordo); + font-size: 12px; + font-weight: 600; + color: var(--tm-testo-medio); +} + +.tm-griglia-2 { + display: grid; + grid-template-columns: 1fr; + gap: 22px; +} +@media (min-width: 900px) { + .tm-griglia-2 { grid-template-columns: 1fr 1fr; gap: 28px; } +} + +/* ─── Campi ───────────────────────────────────────────────────────────────── */ + +.tm-campo { + display: flex; + flex-direction: column; + gap: 7px; +} +.tm-campo > label { + font-size: 13px; + font-weight: 600; + color: var(--tm-testo-medio); +} +.tm-campo input[type="text"], +.tm-campo input[type="number"], +.tm-campo select, +.tm-elenco-pannello select { + width: 100%; + min-height: 40px; + padding: 8px 12px; + border: 1px solid var(--tm-bordo-forte); + border-radius: var(--tm-raggio-interno); + background: var(--tm-superficie); + color: var(--tm-testo); + font-family: inherit; + font-size: 14px; + transition: border-color .15s ease, box-shadow .15s ease; +} +.tm-campo input:focus, +.tm-campo select:focus, +.tm-elenco-pannello select:focus { + outline: none; + border-color: var(--tm-accento); + box-shadow: 0 0 0 3px var(--tm-accento-tenue); +} +.tm-campo input::placeholder { color: var(--tm-testo-tenue); } + +.tm-campo textarea { + width: 100%; + min-height: 120px; + padding: 10px 12px; + border: 1px solid var(--tm-bordo-forte); + border-radius: var(--tm-raggio-interno); + background: var(--tm-superficie); + color: var(--tm-testo); + font-family: inherit; + font-size: 14px; + line-height: 1.55; + resize: vertical; + transition: border-color .15s ease, box-shadow .15s ease; +} +.tm-campo textarea:focus { + outline: none; + border-color: var(--tm-accento); + box-shadow: 0 0 0 3px var(--tm-accento-tenue); +} +.tm-campo textarea::placeholder { color: var(--tm-testo-tenue); } + +/* Gruppo di campi: dà un nome a una parte della scheda senza trasformarla in + una scatola dentro la scatola — solo una riga di separazione e l'etichetta. */ +.tm-gruppo { + display: flex; + flex-direction: column; + gap: 16px; + margin: 0; + padding: 0; + border: 0; +} +.tm-gruppo + .tm-gruppo { + padding-top: 20px; + border-top: 1px solid var(--tm-bordo); +} +.tm-gruppo > legend { + padding: 0; + margin-bottom: 2px; + font-size: 11.5px; + font-weight: 600; + letter-spacing: .05em; + text-transform: uppercase; + color: var(--tm-testo-tenue); +} + +/* La riga "crea quello che manca": sta sotto la tendina a cui appartiene, e si + legge come una sua appendice invece che come un campo a sé. */ +.tm-campo-crea { + gap: 8px; + padding-left: 10px; + border-left: 2px solid var(--tm-bordo); +} +.tm-campo-crea input { min-height: 34px; font-size: 13px; } + +.tm-campo-riga { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} +.tm-campo-riga input, +.tm-campo-riga select { flex: 1 1 180px; } + +.tm-file { + width: 100%; + font-size: 14px; + color: var(--tm-testo-medio); +} +.tm-file::file-selector-button { + margin-right: 12px; + padding: 8px 14px; + border: 1px solid var(--tm-bordo-forte); + border-radius: var(--tm-raggio-interno); + background: var(--tm-superficie-alt); + color: var(--tm-testo-medio); + font-family: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background-color .15s ease; +} +.tm-file::file-selector-button:hover { background: var(--tm-bordo); } + +.tm-campo-aiuto { + margin: 0; + max-width: 62ch; + font-size: 12.5px; + line-height: 1.55; + color: var(--tm-testo-tenue); +} +.tm-campo-aiuto-attesa { color: var(--tm-attesa); } +.tm-campo-errore { + margin: 0; + font-size: 12.5px; + font-weight: 500; + color: var(--tm-pericolo); +} + +/* ─── Elenchi ─────────────────────────────────────────────────────────────── */ + +.tm-elenco { + list-style: none; + margin: 0; + padding: 0; + border-top: 1px solid var(--tm-bordo); +} +.tm-elenco-riga { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 16px; + padding: 14px 2px; + border-bottom: 1px solid var(--tm-bordo); +} +.tm-elenco-intestazione { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + flex: 1 1 260px; + min-width: 0; +} +.tm-elenco-nome { + font-size: 14.5px; + font-weight: 600; + color: var(--tm-testo); + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color .15s ease, color .15s ease; +} +.tm-elenco-nome:hover { + color: var(--tm-accento); + border-bottom-color: var(--tm-accento); +} +.tm-elenco-stato { + font-size: 12.5px; + color: var(--tm-testo-tenue); +} +.tm-elenco-stato-ok { color: var(--tm-ok); } +.tm-elenco-azioni { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.tm-elenco-pannello { + flex: 1 1 100%; + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 4px; + padding: 14px; + border: 1px solid var(--tm-bordo); + border-radius: var(--tm-raggio-interno); + background: var(--tm-superficie-alt); +} + +/* ─── Etichette di stato ──────────────────────────────────────────────────── */ + +.tm-tag { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 5px; + border: 1px solid var(--tm-bordo-forte); + background: var(--tm-superficie-alt); + font-size: 11.5px; + font-weight: 600; + letter-spacing: .01em; + color: var(--tm-testo-medio); +} +.tm-tag-attesa { + border-color: #e8d3ae; + background: var(--tm-attesa-tenue); + color: var(--tm-attesa); +} +.tm-tag-ok { + border-color: #bcd9c9; + background: var(--tm-ok-tenue); + color: var(--tm-ok); +} + +.tm-nota { + padding: 12px 14px; + border: 1px solid var(--tm-bordo); + border-radius: var(--tm-raggio-interno); + background: var(--tm-superficie-alt); + font-size: 13px; + color: var(--tm-testo-medio); +} +.tm-nota p { margin: 0 0 8px; } +.tm-nota-attesa { + border-color: #e8d3ae; + background: var(--tm-attesa-tenue); + color: var(--tm-attesa); +} + +.tm-collegamento { + font-size: 13px; + font-weight: 500; + color: var(--tm-accento); + text-decoration: none; + border-bottom: 1px solid color-mix(in srgb, var(--tm-accento) 35%, transparent); + transition: border-color .15s ease; +} +.tm-collegamento:hover { border-bottom-color: var(--tm-accento); } + +/* ─── Stato vuoto ─────────────────────────────────────────────────────────── */ + +.tm-vuoto { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 40px 20px 44px; + text-align: center; + color: var(--tm-testo-tenue); +} +.tm-vuoto svg { + width: 40px; + height: 40px; + opacity: .3; +} +.tm-vuoto p { + margin: 0; + font-size: 14px; +} + +/* ─── Tabelle ─────────────────────────────────────────────────────────────── */ + +.tm-tabella-scorre { overflow-x: auto; } + +.tm-tabella { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} +.tm-tabella thead th { + padding: 0 12px 10px 0; + border-bottom: 1px solid var(--tm-bordo); + text-align: left; + font-size: 11.5px; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--tm-testo-tenue); +} +.tm-tabella tbody td { + padding: 11px 12px 11px 0; + border-bottom: 1px solid var(--tm-bordo); + color: var(--tm-testo-medio); + vertical-align: middle; +} +.tm-tabella tbody tr:last-child td { border-bottom: 0; } +.tm-tabella-forte { font-weight: 600; color: var(--tm-testo) !important; } +.tm-tabella .cell-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding-right: 0; } /* ─── Pulsanti ───────────────────────────────────────────────────────────── @@ -110,21 +699,26 @@ gap: 6px; min-height: 40px; padding: 10px 16px; - border-radius: 10px; + border-radius: var(--tm-raggio-interno); border: 1px solid transparent; + font-family: inherit; font-size: 14px; - font-weight: 500; + font-weight: 600; line-height: 1.2; white-space: nowrap; text-decoration: none; cursor: pointer; - transition: background-color .15s ease, border-color .15s ease, color .15s ease; + transition: background-color .15s ease, border-color .15s ease, + color .15s ease, transform .1s ease; } .tm-btn svg { width: 16px; height: 16px; flex: none; } +/* Un premuto che si sente: senza, il pulsante resta un rettangolo che non + risponde finche' la pagina non torna dal server. */ +.tm-btn:active:not(:disabled) { transform: translateY(1px); } /* Un anello unico per ogni variante: il colore del focus non deve dipendere da quello del pulsante, altrimenti su rosso o su bianco cambia visibilita' @@ -132,8 +726,11 @@ leggibile sia sulle card bianche sia sui riquadri colorati. */ .tm-btn:focus-visible, .tm-console-btn:focus-visible, -.tm-nav-zona:focus-visible { - outline: 3px solid #1d4ed8; +.tm-nav-zona:focus-visible, +.tm-editor-torna:focus-visible, +.tm-collegamento:focus-visible, +.tm-elenco-nome:focus-visible { + outline: 3px solid var(--tm-accento); outline-offset: 2px; box-shadow: 0 0 0 5px #fff; } @@ -144,43 +741,44 @@ cursor: not-allowed; } -.tm-btn-primary { background: #4f46e5; border-color: #3730a3; color: #fff; } -.tm-btn-primary:hover:not(:disabled) { background: #4338ca; } +.tm-btn-primary { background: var(--tm-accento); border-color: var(--tm-accento-scuro); color: #fff; } +.tm-btn-primary:hover:not(:disabled) { background: var(--tm-accento-scuro); } -.tm-btn-danger { background: #dc2626; border-color: #991b1b; color: #fff; font-weight: 600; } -.tm-btn-danger:hover:not(:disabled) { background: #b91c1c; } +.tm-btn-danger { background: var(--tm-pericolo); border-color: #8e2a23; color: #fff; } +.tm-btn-danger:hover:not(:disabled) { background: #8e2a23; } -.tm-btn-secondary { background: #fff; border-color: #d1d5db; color: #374151; } -.tm-btn-secondary:hover:not(:disabled) { background: #f9fafb; border-color: #9ca3af; } +.tm-btn-secondary { background: var(--tm-superficie); border-color: var(--tm-bordo-forte); color: var(--tm-testo-medio); } +.tm-btn-secondary:hover:not(:disabled) { background: var(--tm-superficie-alt); border-color: var(--tm-testo-tenue); color: var(--tm-testo); } .tm-btn-ghost { background: transparent; border-color: transparent; - color: #4b5563; + color: var(--tm-testo-medio); min-height: 32px; padding: 4px 8px; font-size: 13px; + font-weight: 500; text-decoration: underline; + text-underline-offset: 3px; } -.tm-btn-ghost:hover:not(:disabled) { color: #111827; background: #f3f4f6; } +.tm-btn-ghost:hover:not(:disabled) { color: var(--tm-testo); background: #efedea; } /* Tono tenue: stessa gerarchia, voce piu' bassa. Nelle righe di un elenco i pulsanti sono tanti quanti i record, e il pieno li trasforma in rumore — soprattutto il rosso, che smette di significare "attenzione" quando compare cinquanta volte di fila. */ .tm-btn-tenue { border-color: transparent; font-weight: 600; } -.tm-btn-tenue.tm-btn-primary { background: #e0e7ff; color: #4338ca; } -.tm-btn-tenue.tm-btn-primary:hover:not(:disabled) { background: #c7d2fe; } -.tm-btn-tenue.tm-btn-danger { background: #fee2e2; color: #991b1b; } -.tm-btn-tenue.tm-btn-danger:hover:not(:disabled) { background: #fecaca; } -.tm-btn-tenue.tm-btn-secondary { background: #f3f4f6; color: #374151; } -.tm-btn-tenue.tm-btn-secondary:hover:not(:disabled) { background: #e5e7eb; } +.tm-btn-tenue.tm-btn-primary { background: var(--tm-accento-tenue); color: var(--tm-accento-scuro); } +.tm-btn-tenue.tm-btn-primary:hover:not(:disabled) { background: #dedef3; } +.tm-btn-tenue.tm-btn-danger { background: var(--tm-pericolo-tenue); color: var(--tm-pericolo); } +.tm-btn-tenue.tm-btn-danger:hover:not(:disabled) { background: #f6dcda; } +.tm-btn-tenue.tm-btn-secondary { background: #efedea; color: var(--tm-testo-medio); } +.tm-btn-tenue.tm-btn-secondary:hover:not(:disabled) { background: #e3e0da; color: var(--tm-testo); } /* Tono velato: per i comandi che stanno SOPRA una superficie colorata — oggi - solo la testata, che ha il suo gradiente. Un pulsante bianco o indaco lì sopra - sarebbe una toppa; qui il colore lo dà lo sfondo e il pulsante si limita a - velarlo. Il focus diventa bianco, perche' l'anello blu su indaco non si - distingue. */ + solo la testata. Un pulsante bianco o indaco lì sopra sarebbe una toppa; qui + il colore lo dà lo sfondo e il pulsante si limita a velarlo. Il focus diventa + bianco, perche' l'anello indaco su indaco non si distingue. */ .tm-btn-velato { background: rgba(255, 255, 255, .12); border-color: transparent; @@ -193,7 +791,7 @@ box-shadow: 0 0 0 5px rgba(0, 0, 0, .35); } -.tm-btn-sm { min-height: 32px; padding: 6px 12px; font-size: 13px; gap: 4px; } +.tm-btn-sm { min-height: 32px; padding: 6px 12px; font-size: 13px; gap: 5px; } .tm-btn-sm svg { width: 14px; height: 14px; } /* Solo icona: quadrato, senza testo. Serve sempre un aria-label, altrimenti chi @@ -218,21 +816,21 @@ animation: tmEvidenzia 1.6s ease-out; } @keyframes tmEvidenzia { - 0% { box-shadow: 0 0 0 0 rgba(29, 78, 216, .6); } - 12% { box-shadow: 0 0 0 7px rgba(29, 78, 216, .35); } - 100% { box-shadow: 0 0 0 0 rgba(29, 78, 216, 0); } + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--tm-accento) 60%, transparent); } + 12% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--tm-accento) 32%, transparent); } + 100% { box-shadow: 0 0 0 0 transparent; } } /* Zona con un'operazione in corso: resta marcata finche' dura, cosi' anche tornando sulla pagina dopo un ricaricamento si vede subito chi sta lavorando. */ .tm-zona-attiva { - box-shadow: 0 0 0 2px #4f46e5; + box-shadow: 0 0 0 2px var(--tm-accento); } /* Chi ha chiesto meno animazioni riceve comunque il segnale, fermo. */ @media (prefers-reduced-motion: reduce) { - .tm-zona-evidenziata { animation: none; box-shadow: 0 0 0 3px rgba(29, 78, 216, .5); } - .tm-btn { transition: none; } + .tm-zona-evidenziata { animation: none; box-shadow: 0 0 0 3px var(--tm-accento); } + .tm-btn, .card-hover, .page-enter { transition: none; animation: none; } } /* Indice delle zone, appiccicato in alto. */ @@ -245,10 +843,10 @@ gap: 6px; padding: 10px 12px; margin: -4px 0 0; - border: 1px solid #e5e7eb; - border-radius: 12px; - background: rgba(255, 255, 255, .92); - backdrop-filter: blur(6px); + border: 1px solid var(--tm-bordo); + border-radius: var(--tm-raggio); + background: rgba(255, 255, 255, .9); + backdrop-filter: blur(8px); } .tm-nav-zona { display: inline-flex; @@ -256,26 +854,23 @@ gap: 6px; padding: 5px 10px; border-radius: 999px; - border: 1px solid #e5e7eb; - background: #fff; - color: #374151; + border: 1px solid var(--tm-bordo); + background: var(--tm-superficie); + color: var(--tm-testo-medio); font-size: 12px; font-weight: 500; text-decoration: none; cursor: pointer; + transition: background-color .15s ease, border-color .15s ease, color .15s ease; } -.tm-nav-zona:hover { background: #eef2ff; border-color: #c7d2fe; color: #3730a3; } -.tm-nav-zona-pericolo { color: #991b1b; border-color: #fecaca; } -.tm-nav-zona-pericolo:hover { background: #fef2f2; border-color: #fca5a5; color: #7f1d1d; } +.tm-nav-zona:hover { background: var(--tm-accento-tenue); border-color: var(--tm-accento); color: var(--tm-accento-scuro); } +.tm-nav-zona-pericolo { color: var(--tm-pericolo); border-color: #edc9c6; } +.tm-nav-zona-pericolo:hover { background: var(--tm-pericolo-tenue); border-color: var(--tm-pericolo); color: #8e2a23; } /* ─── Console delle operazioni ───────────────────────────────────────────── Una sola finestra per tutte le operazioni lunghe, agganciata in fondo alla finestra del browser: quello che sta succedendo non deve uscire dallo schermo - mentre si scorre la pagina. Chiusa e' una striscia alta 44px. - - Il posto in cui vive e' il corpo della pagina, ma la posizione e' fissa, - quindi in fondo alla pagina c'e' uno spaziatore della stessa altezza: senza, - coprirebbe l'ultima zona — che e' quella pericolosa. */ + mentre si scorre la pagina. Chiusa e' una striscia alta 44px. */ .tm-console { position: fixed; left: 0; @@ -284,10 +879,10 @@ z-index: 45; display: flex; flex-direction: column; - background: #0b1020; - border-top: 1px solid #1f2937; - box-shadow: 0 -8px 24px -12px rgba(0, 0, 0, .5); - color: #e5e7eb; + background: #17161f; + border-top: 1px solid #2c2a38; + box-shadow: 0 -8px 28px -14px rgba(20, 16, 12, .6); + color: #e6e3dd; } .tm-console-testata { display: flex; @@ -303,21 +898,24 @@ background: none; border: 0; padding: 0; - color: #e5e7eb; + color: #e6e3dd; + font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer; } .tm-console-btn { background: transparent; - border: 1px solid #374151; + border: 1px solid #3d3a4a; border-radius: 8px; padding: 4px 10px; - color: #d1d5db; + color: #cfcbc2; + font-family: inherit; font-size: 12px; cursor: pointer; + transition: background-color .15s ease, color .15s ease; } -.tm-console-btn:hover { background: #1f2937; color: #fff; } +.tm-console-btn:hover { background: #2c2a38; color: #fff; } /* Il corpo scorre; l'altezza e' in vh perche' su un portatile basso una finestra fissa in pixel si mangerebbe mezza pagina. */ @@ -330,40 +928,38 @@ line-height: 1.55; } .tm-console-riga { display: flex; gap: 8px; white-space: pre-wrap; word-break: break-word; } -.tm-console-ora { color: #6b7280; flex: none; } -.tm-console-sorgente { color: #818cf8; flex: none; } -.tm-console-riga-errore .tm-console-testo { color: #fca5a5; font-weight: 600; } -.tm-console-riga-ok .tm-console-testo { color: #6ee7b7; } -.tm-console-riga-attesa .tm-console-testo { color: #fcd34d; } +.tm-console-ora { color: #837d74; flex: none; } +.tm-console-sorgente { color: #a09ed8; flex: none; } +.tm-console-riga-errore .tm-console-testo { color: #e8a09a; font-weight: 600; } +.tm-console-riga-ok .tm-console-testo { color: #8fd0ac; } +.tm-console-riga-attesa .tm-console-testo { color: #e2bd7e; } -/* Barra di avanzamento nella testata: sottile, sempre allo stesso posto. */ .tm-console-barra { flex: 1; height: 6px; min-width: 60px; border-radius: 999px; - background: #1f2937; + background: #2c2a38; overflow: hidden; } .tm-console-barra > div { height: 100%; border-radius: 999px; - background: #6366f1; + background: #7c79c5; transition: width .3s ease; } -.tm-console-barra-ok > div { background: #10b981; } -.tm-console-barra-errore > div { background: #ef4444; } +.tm-console-barra-ok > div { background: #3f8b64; } +.tm-console-barra-errore > div { background: #c9564c; } -/* "Sei tornato indietro a leggere": lo scorrimento automatico si sospende e - compare il modo per riagganciarsi. */ .tm-console-torna { align-self: center; margin: 0 12px 8px; - background: #312e81; - border: 1px solid #4f46e5; + background: #2f2c6c; + border: 1px solid var(--tm-accento); border-radius: 999px; padding: 3px 12px; - color: #e0e7ff; + color: #dedef3; + font-family: inherit; font-size: 11px; cursor: pointer; } @@ -373,6 +969,227 @@ .tm-console-sorgente { display: none; } } +/* ─── Editor delle piantine, a schermo intero ────────────────────────────── + La mappa prende tutta la finestra e i comandi stanno in una striscia sottile + in alto. Tagliare un confine su una cartografia grande un terzo di schermo + vuol dire sbagliarlo, e questo disegno finisce stampato su un modulo che poi + gira di mano in mano. */ +.tm-schermo { + height: 100dvh; + display: flex; + flex-direction: column; +} +.tm-editor { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.tm-editor-barra { + display: flex; + align-items: center; + gap: 12px; + flex: none; + padding: 9px 14px; + background: var(--tm-superficie); + border-bottom: 1px solid var(--tm-bordo); + box-shadow: var(--tm-ombra-1); + z-index: 10; +} +.tm-editor-torna { + display: inline-flex; + align-items: center; + gap: 5px; + flex: none; + padding: 5px 8px 5px 4px; + border-radius: 8px; + color: var(--tm-testo-medio); + font-size: 13.5px; + font-weight: 600; + text-decoration: none; + transition: background-color .15s ease, color .15s ease; +} +.tm-editor-torna:hover { background: var(--tm-superficie-alt); color: var(--tm-testo); } +.tm-editor-torna svg { width: 17px; height: 17px; } + +.tm-editor-sep { + width: 1px; + height: 22px; + flex: none; + background: var(--tm-bordo); +} + +.tm-editor-titolo { + display: flex; + align-items: center; + gap: 9px; + flex: 1 1 auto; + min-width: 0; + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--tm-testo); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tm-editor-azioni { + display: flex; + align-items: center; + gap: 8px; + flex: none; +} + +.tm-editor-mappa { + position: relative; + flex: 1 1 auto; + min-height: 0; +} +.tm-mappa { + position: absolute; + inset: 0; + background: #efedea; +} + +.tm-editor-nota { + position: absolute; + left: 50%; + top: 14px; + transform: translateX(-50%); + z-index: 500; + max-width: min(92vw, 460px); + margin: 0; + padding: 9px 14px; + border: 1px solid #e8d3ae; + border-radius: 999px; + background: var(--tm-attesa-tenue); + box-shadow: var(--tm-ombra-2); + font-size: 12.5px; + text-align: center; + color: var(--tm-attesa); +} +.tm-editor-nota a { color: inherit; font-weight: 600; } + +/* Il pannello galleggia sopra la mappa invece di restringerla: se togliesse + spazio, aprirlo per cambiare un nome sposterebbe il disegno sotto il + puntatore. */ +.tm-editor-pannello { + position: absolute; + top: 12px; + right: 12px; + bottom: 12px; + z-index: 600; + width: min(360px, calc(100vw - 24px)); + display: flex; + flex-direction: column; + border: 1px solid var(--tm-bordo); + border-radius: var(--tm-raggio); + background: var(--tm-superficie); + box-shadow: var(--tm-ombra-3); + overflow: hidden; +} +.tm-editor-pannello[hidden] { display: none; } + +.tm-editor-pannello-testata { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex: none; + padding: 13px 16px; + border-bottom: 1px solid var(--tm-bordo); +} +.tm-editor-pannello-testata h2 { + margin: 0; + font-size: 14px; + font-weight: 600; +} +.tm-editor-chiudi { + background: none; + border: 0; + padding: 2px 6px; + border-radius: 7px; + color: var(--tm-testo-tenue); + font-size: 20px; + line-height: 1; + cursor: pointer; + transition: background-color .15s ease, color .15s ease; +} +.tm-editor-chiudi:hover { background: var(--tm-superficie-alt); color: var(--tm-testo); } + +.tm-editor-pannello-corpo { + display: flex; + flex-direction: column; + gap: 20px; + padding: 16px; + overflow-y: auto; +} +.tm-editor-pannello-piede { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-top: 16px; + border-top: 1px solid var(--tm-bordo); +} +.tm-editor-aiuto { + margin: 0; + font-size: 12.5px; + line-height: 1.55; + color: var(--tm-testo-tenue); +} + +@media (max-width: 720px) { + .tm-editor-barra { flex-wrap: wrap; } + .tm-editor-titolo { order: 3; flex-basis: 100%; } + .tm-editor-pannello { top: auto; height: min(70dvh, 460px); } +} + +/* ─── Avvisi galleggianti (solo telaio a schermo intero) ───────────────────── */ + +.tm-avvisi { + position: fixed; + top: 14px; + left: 50%; + transform: translateX(-50%); + z-index: 900; + display: flex; + flex-direction: column; + gap: 8px; + width: min(92vw, 520px); + pointer-events: none; +} +.tm-avviso { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 14px; + border-radius: var(--tm-raggio-interno); + border: 1px solid var(--tm-bordo); + background: var(--tm-superficie); + box-shadow: var(--tm-ombra-3); + font-size: 13.5px; + pointer-events: auto; +} +.tm-avviso svg { width: 18px; height: 18px; flex: none; } +.tm-avviso-ok { border-color: #bcd9c9; background: var(--tm-ok-tenue); color: var(--tm-ok); } +.tm-avviso-errore { border-color: #edc9c6; background: var(--tm-pericolo-tenue); color: var(--tm-pericolo); } +.tm-avviso-chiudi { + margin-left: auto; + background: none; + border: 0; + padding: 0 4px; + color: inherit; + font-size: 19px; + line-height: 1; + opacity: .6; + cursor: pointer; +} +.tm-avviso-chiudi:hover { opacity: 1; } + /* ─── Responsive list tables ─────────────────────────────────────────────── Under 640px (a phone held in portrait; rotate to landscape and the width grows past the breakpoint, restoring the normal table) every table with the @@ -391,15 +1208,15 @@ } table.table-cards tr { display: block; - border: 1px solid #e5e7eb; - border-radius: 10px; + border: 1px solid var(--tm-bordo); + border-radius: var(--tm-raggio-interno); margin-bottom: 12px; padding: 4px 2px; - background: #fff; + background: var(--tm-superficie); } /* zebra striping would fight the card borders */ table.table-cards.table-striped tbody tr:nth-child(even) { - background: #fff; + background: var(--tm-superficie); } table.table-cards td { display: flex; @@ -418,9 +1235,9 @@ text-align: left; font-size: 11px; font-weight: 600; - color: #6b7280; + color: var(--tm-testo-tenue); text-transform: uppercase; - letter-spacing: .3px; + letter-spacing: .04em; } /* Actions row: full width, buttons wrapped to the right */ table.table-cards td.cell-actions { @@ -428,7 +1245,7 @@ flex-wrap: wrap; margin-top: 2px; padding-top: 10px; - border-top: 1px solid #f3f4f6 !important; + border-top: 1px solid var(--tm-bordo) !important; } /* Cells spanning all columns (empty state, inline edit form) stay full width and block, so their inner layout isn't squeezed by the flex row. */ diff --git a/resources/js/piantine.js b/resources/js/piantine.js index 622da35..63a37c1 100644 --- a/resources/js/piantine.js +++ b/resources/js/piantine.js @@ -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: '© OpenStreetMap 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') { diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php index c2ff916..802a6e9 100644 --- a/resources/views/components/layouts/app.blade.php +++ b/resources/views/components/layouts/app.blade.php @@ -1,5 +1,5 @@ - + @@ -9,6 +9,8 @@ @livewireStyles + {{-- Chi naviga da tastiera non deve ripercorrere il menu a ogni pagina. --}} + Salta al contenuto
{{-- Header --}}