Editor piantine: bozza automatica e pubblicazione con anteprima

- Il disegno è bozza e si salva da solo; i dati del territorio si
  modificano dall'editor e valgono subito nel gestionale.
- Un solo "Pubblica" (immagine + PDF), uguale in editor e gestione:
  anteprima preparata in coda su disco privato, conferma obbligatoria,
  rifiutata se nel frattempo è cambiato qualcosa di stampato.
- Stati Bozza / Pubblicata / Modifiche da pubblicare, calcolati
  sull'impronta di confine e dati stampati e sul PDF in uso.
- Gestione riscritta: stato, ricerca, filtri, storico paginato.
- Editor ridisegnato a tre zone, utilizzabile anche su tablet e telefono.
- Avvisi via evento (trait Avvisa + <x-avvisi>).
- .gitattributes: script shell sempre LF, altrimenti il container
  locale su Windows non parte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 12:08:34 +02:00
parent d433c9392d
commit 056d95cfcb
25 changed files with 3084 additions and 980 deletions

View File

@@ -3,6 +3,7 @@
namespace App\Services\Piantine;
use App\Models\TerritorioMappa;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
@@ -14,6 +15,11 @@ use RuntimeException;
*/
class PiantinaRenderer
{
/**
* Rendering e archiviazione come immagine della piantina.
*
* @return string il percorso sul disco pubblico
*/
public function render(TerritorioMappa $mappa): string
{
$geometry = $mappa->geometria();
@@ -22,19 +28,38 @@ class PiantinaRenderer
throw new RuntimeException('La geometria salvata per questa piantina non è leggibile: ridisegna il poligono.');
}
[$immagine, $estensione] = $this->immagine($geometry);
return $this->archivia($mappa, $immagine, $estensione);
}
/**
* Solo il rendering, senza toccare niente: serve all'anteprima della
* pubblicazione, che non deve cambiare la piantina finché non si conferma.
*
* @return array{0: string, 1: string} contenuto e estensione ('jpg'|'png')
*/
public function immagine(array $geometry): array
{
$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'),
]);
try {
$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'),
]);
} catch (ConnectionException $e) {
// Container fermo, nome non risolto o tempo scaduto: il messaggio
// di cURL in inglese non dice niente a chi sta pubblicando.
throw new RuntimeException('Il servizio che disegna le mappe non risponde (container map-renderer). Riprova fra qualche minuto; se continua, va controllato il server.', 0, $e);
}
if (! $response->successful()) {
$dettaglio = $response->json('error') ?? ('HTTP ' . $response->status());
@@ -47,15 +72,28 @@ class PiantinaRenderer
throw new RuntimeException('Il renderer delle mappe non ha restituito un\'immagine valida.');
}
// 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);
return [$immagine, $formato === 'png' ? 'png' : 'jpg'];
}
/**
* Salva l'immagine come quella della piantina, rimuovendo la precedente.
*
* Il nome cambia a ogni rendering: nginx e Cloudflare tengono in cache i
* file di /storage per giorni, e con un nome fisso il link mostrava
* l'immagine vecchia anche dopo averla rigenerata.
*/
public function archivia(TerritorioMappa $mappa, string $immagine, string $estensione): string
{
$disco = Storage::disk('public');
$path = 'territori-mappe/piantina-' . $mappa->id . '-' . now()->format('YmdHis') . '.' . $estensione;
if (! $disco->put($path, $immagine)) {
throw new RuntimeException('Non è stato possibile salvare l\'immagine della piantina sul disco.');
}
$path = $this->percorso($mappa, $formato);
Storage::disk('public')->put($path, $immagine);
if ($mappa->image_path && $mappa->image_path !== $path) {
$disco->delete($mappa->image_path);
}
$mappa->update([
'image_path' => $path,
@@ -65,11 +103,6 @@ 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
{