++ Prima versione del designer piantine + Skill grafica

This commit is contained in:
2026-08-28 15:32:14 +02:00
parent 8aa29cc467
commit 7e4c8aa9b8
62 changed files with 16340 additions and 4 deletions

View File

@@ -0,0 +1,198 @@
<?php
namespace App\Services\Piantine;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Services\TerritorioThumbnailService;
use Illuminate\Support\Facades\Storage;
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.
*
* 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).
*/
class PiantinaPdfService
{
public function __construct(
private PiantineArea $area,
private TerritorioThumbnailService $thumbnails,
) {
}
/** Il PDF completo, come stringa binaria. */
public function genera(Territorio $territorio, TerritorioMappa $mappa): string
{
if (! $mappa->image_path || ! Storage::disk('public')->exists($mappa->image_path)) {
throw new RuntimeException('Prima di generare il PDF va generata l\'immagine della piantina.');
}
$immagine = Storage::disk('public')->path($mappa->image_path);
$dimensioni = getimagesize($immagine);
if ($dimensioni === false) {
throw new RuntimeException('L\'immagine della piantina non è leggibile: rigenerala.');
}
[$imgPxW, $imgPxH] = $dimensioni;
$pdf = new Fpdi();
$pdf->setSourceFile(resource_path('pdf/S-12_I.pdf'));
$template = $pdf->importPage(1);
$misure = $pdf->getTemplateSize($template);
$w = $misure['width'];
$h = $misure['height'];
$pdf->AddPage($misure['orientation'], [$w, $h]);
$pdf->useTemplate($template);
// 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');
$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;
$scala = min($boxW / $imgPxW, $boxH / $imgPxH);
$imgW = $imgPxW * $scala;
$imgH = $imgPxH * $scala;
$pdf->Image(
$immagine,
$boxX + ($boxW - $imgW) / 2,
$boxY + ($boxH - $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.')
);
return $pdf->Output('S');
}
/**
* Genera il PDF e lo rende quello ufficiale del territorio, archiviando
* l'eventuale PDF precedente nello storico versioni.
*/
public function sostituisci(Territorio $territorio, TerritorioMappa $mappa, string $origine = 'piantine'): void
{
$binario = $this->genera($territorio, $mappa);
$disk = Storage::disk('public');
$this->archiviaCorrente($territorio, $origine);
$nuovoPath = 'territori-pdf/piantina-' . $territorio->id . '-' . now()->format('Ymd-His') . '.pdf';
$disk->put($nuovoPath, $binario);
$territorio->update([
'pdf_path' => $nuovoPath,
'thumbnail_path' => $this->thumbnails->generate($nuovoPath),
]);
activity()
->causedBy(auth()->user())
->performedOn($territorio)
->log('piantina_pdf_sostituito');
}
/**
* Rimette in vigore una versione archiviata; il PDF attuale finisce a sua
* volta nello storico, quindi il ripristino è sempre reversibile.
*/
public function ripristina(TerritorioPdfVersione $versione): void
{
$disk = Storage::disk('public');
$territorio = $versione->territorio;
if (! $territorio) {
throw new RuntimeException('La versione non appartiene più a nessun territorio.');
}
if (! $disk->exists($versione->pdf_path)) {
throw new RuntimeException('Il file di questa versione non esiste più sul disco.');
}
$this->archiviaCorrente($territorio, 'ripristino');
$nuovoPath = 'territori-pdf/ripristino-' . $territorio->id . '-' . now()->format('Ymd-His') . '.pdf';
$disk->move($versione->pdf_path, $nuovoPath);
$territorio->update([
'pdf_path' => $nuovoPath,
'thumbnail_path' => $this->thumbnails->generate($nuovoPath),
]);
$versione->delete();
activity()
->causedBy(auth()->user())
->performedOn($territorio)
->log('piantina_pdf_ripristinato');
}
/**
* Sposta il PDF attuale del territorio (se esiste) nello storico.
*/
private function archiviaCorrente(Territorio $territorio, string $origine): void
{
$disk = Storage::disk('public');
if (! $territorio->pdf_path || ! $disk->exists($territorio->pdf_path)) {
return;
}
$versionePath = 'territori-pdf-versioni/territorio-' . $territorio->id
. '-' . now()->format('Ymd-His') . '-' . substr(uniqid(), -5) . '.pdf';
$disk->makeDirectory('territori-pdf-versioni');
$disk->move($territorio->pdf_path, $versionePath);
TerritorioPdfVersione::create([
'territorio_id' => $territorio->id,
'pdf_path' => $versionePath,
'thumbnail_path' => $territorio->thumbnail_path,
'origine' => $origine,
'sostituito_il' => now(),
]);
}
/** FPDF ragiona in windows-1252: gli accenti UTF-8 vanno convertiti. */
private function latin(string $testo): string
{
$convertito = @iconv('UTF-8', 'windows-1252//TRANSLIT', $testo);
return $convertito !== false ? $convertito : $testo;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Services\Piantine;
use App\Models\TerritorioMappa;
use Illuminate\Support\Facades\Http;
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.
*/
class PiantinaRenderer
{
public function render(TerritorioMappa $mappa): string
{
$geometry = $mappa->geometria();
if ($geometry === null) {
throw new RuntimeException('La geometria salvata per questo territorio non è leggibile: ridisegna il poligono.');
}
$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'),
]);
if (! $response->successful()) {
$dettaglio = $response->json('error') ?? ('HTTP ' . $response->status());
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.');
}
$path = 'territori-mappe/territorio-' . $mappa->territorio_id . '.png';
Storage::disk('public')->put($path, $png);
$mappa->update([
'image_path' => $path,
'rendered_at' => now(),
]);
return $path;
}
}

View File

@@ -0,0 +1,189 @@
<?php
namespace App\Services\Piantine;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
/**
* L'area della città su cui si disegnano i territori.
*
* 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.
*/
class PiantineArea
{
private const DIR = 'piantine';
private const FILE_KML = 'piantine/area.kml';
private const FILE_GEOJSON = 'piantine/area.geojson';
private const FILE_CONFIG = 'piantine/config.json';
/**
* Registra un nuovo KML come area attiva.
*
* @return int Numero di geometrie riconosciute nel file.
*/
public function salvaKml(string $contenuto, string $nomeOriginale): int
{
$geojson = $this->kmlInGeoJson($contenuto);
$quante = count($geojson['features']);
if ($quante === 0) {
throw new RuntimeException(
'Nel file KML non è stato trovato nessun poligono o linea: '
. 'serve almeno un perimetro che delimiti l\'area della città.'
);
}
Storage::disk('local')->makeDirectory(self::DIR);
Storage::disk('local')->put(self::FILE_KML, $contenuto);
Storage::disk('local')->put(self::FILE_GEOJSON, json_encode($geojson, JSON_UNESCAPED_UNICODE));
$config = $this->config();
$config['kml_nome'] = $nomeOriginale;
$config['aggiornato_il'] = now()->toDateTimeString();
$this->salvaConfig($config);
return $quante;
}
/** GeoJSON FeatureCollection dell'area attiva, o null se mai caricata. */
public function area(): ?array
{
if (! Storage::disk('local')->exists(self::FILE_GEOJSON)) {
return null;
}
$decoded = json_decode(Storage::disk('local')->get(self::FILE_GEOJSON), true);
return is_array($decoded) ? $decoded : null;
}
public function config(): array
{
if (! Storage::disk('local')->exists(self::FILE_CONFIG)) {
return ['localita' => null, '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);
}
private function salvaConfig(array $config): void
{
Storage::disk('local')->makeDirectory(self::DIR);
Storage::disk('local')->put(self::FILE_CONFIG, json_encode($config, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
}
// ─── KML → GeoJSON ──────────────────────────────────────────────────────
/**
* Estrae poligoni e linee dai Placemark di un KML (anche annidati in
* Document/Folder e dentro MultiGeometry) e li restituisce come
* FeatureCollection GeoJSON.
*
* Le xpath usano local-name(): i prefissi registrati con
* registerXPathNamespace non si ereditano sui nodi restituiti da xpath(),
* e i KML in circolazione dichiarano il namespace in almeno tre modi
* diversi (OGC, Google legacy, nessuno). Cosi' funzionano tutti.
*/
public function kmlInGeoJson(string $kml): array
{
$precedente = libxml_use_internal_errors(true);
$xml = simplexml_load_string($kml);
libxml_clear_errors();
libxml_use_internal_errors($precedente);
if ($xml === false) {
throw new RuntimeException('Il file non è un XML/KML leggibile.');
}
$placemarks = $xml->xpath("//*[local-name()='Placemark']") ?: [];
$features = [];
foreach ($placemarks as $placemark) {
$nodiNome = $placemark->xpath("./*[local-name()='name']") ?: [];
$nome = trim((string) ($nodiNome[0] ?? ''));
foreach ($this->geometrieDelPlacemark($placemark) as $geometry) {
$features[] = [
'type' => 'Feature',
'properties' => ['name' => $nome],
'geometry' => $geometry,
];
}
}
return ['type' => 'FeatureCollection', 'features' => $features];
}
private function geometrieDelPlacemark(\SimpleXMLElement $placemark): array
{
$geometrie = [];
foreach ($placemark->xpath(".//*[local-name()='Polygon']") ?: [] as $polygon) {
$anelli = [];
foreach ($polygon->xpath(".//*[local-name()='LinearRing']/*[local-name()='coordinates']") ?: [] as $coordinates) {
$anello = $this->coordinateKml((string) $coordinates);
if (count($anello) >= 3) {
$anelli[] = $anello;
}
}
if ($anelli !== []) {
$geometrie[] = ['type' => 'Polygon', 'coordinates' => $anelli];
}
}
// Le LineString servono ai KML che tracciano il perimetro come linea
// invece che come poligono chiuso.
foreach ($placemark->xpath(".//*[local-name()='LineString']/*[local-name()='coordinates']") ?: [] as $coordinates) {
$punti = $this->coordinateKml((string) $coordinates);
if (count($punti) >= 2) {
$geometrie[] = ['type' => 'LineString', 'coordinates' => $punti];
}
}
return $geometrie;
}
/** "lng,lat[,alt] lng,lat[,alt] ..." → [[lng, lat], ...] */
private function coordinateKml(string $testo): array
{
$punti = [];
foreach (preg_split('/\s+/', trim($testo)) ?: [] as $tripla) {
if ($tripla === '') {
continue;
}
$parti = explode(',', $tripla);
if (count($parti) >= 2 && is_numeric($parti[0]) && is_numeric($parti[1])) {
$punti[] = [(float) $parti[0], (float) $parti[1]];
}
}
return $punti;
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Services\Piantine;
use Illuminate\Support\Facades\Cache;
/**
* Stato della rigenerazione massiva delle piantine.
*
* Stessa impostazione di BackupOperationState e TerritorioPdfImportState:
* la pagina segue il lavoro in coda interrogando questo stato, la barra si
* muove su unità di lavoro completate davvero (territori renderizzati).
* Un'operazione alla volta: due rigenerazioni parallele martellerebbero il
* renderer e il tile server senza alcun beneficio.
*/
class PiantineOperationState
{
protected int $ttlSeconds = 86400;
private const MAX_LOG = 60;
private const CHIAVE_CORRENTE = 'piantine-op:corrente';
private const TTL_CORRENTE = 3600;
public function initialize(string $id, int $total, bool $anchePdf): array
{
$state = [
'id' => $id,
'status' => 'queued',
'anche_pdf' => $anchePdf,
'total' => $total,
'processed' => 0,
'falliti' => 0,
'logs' => [],
'error' => null,
'started_at' => null,
'finished_at' => null,
];
$this->put($id, $state);
Cache::put(self::CHIAVE_CORRENTE, $id, self::TTL_CORRENTE);
return $state;
}
public function corrente(): ?string
{
$id = Cache::get(self::CHIAVE_CORRENTE);
if (! is_string($id) || $id === '') {
return null;
}
$stato = $this->get($id);
if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) {
Cache::forget(self::CHIAVE_CORRENTE);
return null;
}
return $id;
}
public function get(string $id): ?array
{
return Cache::get($this->key($id));
}
public function put(string $id, array $state): void
{
Cache::put($this->key($id), $state, $this->ttlSeconds);
}
public function update(string $id, callable $callback): void
{
$state = $this->get($id);
if (! $state) {
return;
}
$this->put($id, $callback($state) ?? $state);
}
public function log(string $id, string $message): void
{
$this->update($id, function (array $state) use ($message) {
$state['logs'][] = '[' . now()->format('H:i:s') . '] ' . $message;
$state['logs'] = array_slice($state['logs'], -self::MAX_LOG);
return $state;
});
}
public function avanza(string $id, int $processed, int $falliti): void
{
$this->update($id, function (array $state) use ($processed, $falliti) {
$state['processed'] = $processed;
$state['falliti'] = $falliti;
return $state;
});
}
public function markRunning(string $id): void
{
$this->update($id, function (array $state) {
$state['status'] = 'running';
$state['started_at'] ??= now()->toDateTimeString();
return $state;
});
}
public function markCompleted(string $id): void
{
$this->update($id, function (array $state) {
$state['status'] = 'completed';
$state['finished_at'] = now()->toDateTimeString();
return $state;
});
$this->liberaCorrente($id);
}
public function markFailed(string $id, string $error): void
{
$this->update($id, function (array $state) use ($error) {
$state['status'] = 'failed';
$state['error'] = $error;
$state['finished_at'] = now()->toDateTimeString();
return $state;
});
$this->log($id, 'ERRORE: ' . $error);
$this->liberaCorrente($id);
}
private function liberaCorrente(string $id): void
{
if (Cache::get(self::CHIAVE_CORRENTE) === $id) {
Cache::forget(self::CHIAVE_CORRENTE);
}
}
protected function key(string $id): string
{
return 'piantine-op:' . $id;
}
}