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