++ 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,99 @@
<?php
namespace App\Jobs;
use App\Models\TerritorioMappa;
use App\Services\Piantine\PiantinaPdfService;
use App\Services\Piantine\PiantinaRenderer;
use App\Services\Piantine\PiantineOperationState;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Rigenerazione massiva delle piantine: per ogni territorio con un poligono
* disegnato rigenera l'immagine dal renderer e, a richiesta, ricompone e
* sostituisce anche il PDF (versione precedente archiviata nello storico).
*
* Un territorio che fallisce non ferma gli altri: viene contato e loggato,
* l'esito finale riepiloga quanti sono andati a buon fine.
*/
class RigeneraPiantine implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 1;
// Rendering + PDF per decine di territori: largo ma non illimitato, per
// non lasciare il worker inchiodato se il renderer smette di rispondere.
public int $timeout = 5400;
public function __construct(
public string $operationId,
public bool $anchePdf,
) {
}
public function handle(
PiantineOperationState $state,
PiantinaRenderer $renderer,
PiantinaPdfService $pdfService,
): void {
$state->markRunning($this->operationId);
$state->log($this->operationId, 'Rigenerazione avviata.');
$mappe = TerritorioMappa::with('territorio')->orderBy('territorio_id')->get();
$processed = 0;
$falliti = 0;
foreach ($mappe as $mappa) {
$territorio = $mappa->territorio;
$etichetta = $territorio ? "territorio {$territorio->numero}" : "mappa #{$mappa->id}";
try {
if (! $territorio) {
throw new \RuntimeException('il territorio associato non esiste più');
}
$renderer->render($mappa);
$state->log($this->operationId, "Immagine rigenerata per {$etichetta}.");
if ($this->anchePdf) {
$pdfService->sostituisci($territorio, $mappa, 'rigenerazione');
$state->log($this->operationId, "PDF sostituito per {$etichetta}.");
}
} catch (\Throwable $e) {
$falliti++;
$state->log($this->operationId, "[ERRORE] {$etichetta}: {$e->getMessage()}");
}
$processed++;
$state->avanza($this->operationId, $processed, $falliti);
}
$state->log(
$this->operationId,
'Rigenerazione conclusa: ' . ($processed - $falliti) . ' riuscite, ' . $falliti . ' fallite.'
);
$state->markCompleted($this->operationId);
}
/**
* Chiude l'operazione quando il job muore fuori da handle() (timeout,
* worker terminato): senza, la pagina resterebbe a interrogare per sempre
* uno stato "running".
*/
public function failed(?\Throwable $e): void
{
app(PiantineOperationState::class)->markFailed(
$this->operationId,
$e?->getMessage() ?? 'Job interrotto in modo inatteso.'
);
}
}

View File

@@ -0,0 +1,317 @@
<?php
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).
*
* 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.
*/
class PiantineManager extends Component
{
use WithFileUploads;
public $kmlFile = null;
public string $localita = '';
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();
}
// ─── Area città (KML) ───────────────────────────────────────────────────
public function caricaKml(PiantineArea $area)
{
$this->validate(
['kmlFile' => 'required|file|max:10240'],
[],
['kmlFile' => 'file KML'],
);
$estensione = strtolower($this->kmlFile->getClientOriginalExtension());
if (! in_array($estensione, ['kml', 'xml'], true)) {
$this->addError('kmlFile', 'Il file deve essere un .kml (o .xml in formato KML).');
return;
}
try {
$quante = $area->salvaKml(
file_get_contents($this->kmlFile->getRealPath()),
$this->kmlFile->getClientOriginalName(),
);
} catch (\Throwable $e) {
$this->addError('kmlFile', $e->getMessage());
return;
}
$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();
}
// ─── Generazione ────────────────────────────────────────────────────────
public function generaImmagine(int $mappaId, PiantinaRenderer $renderer)
{
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
try {
$renderer->render($mappa);
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
return;
}
session()->flash('success', "Immagine generata per il territorio {$mappa->territorio?->numero}.");
}
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ù.');
return;
}
try {
$pdfService->sostituisci($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.");
}
// ─── Rigenerazione massiva ──────────────────────────────────────────────
public function rigeneraTutto(bool $anchePdf, PiantineOperationState $state)
{
if ($state->corrente() !== null) {
session()->flash('error', 'C\'è già una rigenerazione in corso: attendi che finisca.');
return;
}
$totale = TerritorioMappa::count();
if ($totale === 0) {
session()->flash('error', 'Nessun territorio ha ancora un poligono disegnato.');
return;
}
$id = (string) Str::uuid();
$state->initialize($id, $totale, $anchePdf);
RigeneraPiantine::dispatch($id, $anchePdf);
$this->operazioneId = $id;
activity()->causedBy(auth()->user())->log($anchePdf ? 'piantine_rigenerazione_pdf' : 'piantine_rigenerazione');
}
// ─── Storico versioni ───────────────────────────────────────────────────
public function ripristinaVersione(int $versioneId, PiantinaPdfService $pdfService)
{
$versione = TerritorioPdfVersione::with('territorio')->findOrFail($versioneId);
$numero = $versione->territorio?->numero ?? '?';
try {
$pdfService->ripristina($versione);
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
return;
}
session()->flash('success', "Versione precedente ripristinata per il territorio {$numero}.");
}
public function eliminaVersione(int $versioneId)
{
$versione = TerritorioPdfVersione::findOrFail($versioneId);
if (Storage::disk('public')->exists($versione->pdf_path)) {
Storage::disk('public')->delete($versione->pdf_path);
}
$versione->delete();
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
{
$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,
'versioni' => TerritorioPdfVersione::with('territorio')
->orderByDesc('sostituito_il')
->limit(30)
->get(),
'stato' => $stato,
'title' => 'Piantine dei territori',
]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class TerritorioMappa extends Model
{
protected $table = 'territorio_mappe';
protected $fillable = [
'territorio_id',
'geojson',
'image_path',
'rendered_at',
];
protected function casts(): array
{
return [
'rendered_at' => 'datetime',
];
}
public function territorio()
{
return $this->belongsTo(Territorio::class, 'territorio_id');
}
/**
* La geometria decodificata (Polygon/MultiPolygon GeoJSON) o null se
* il contenuto non è più interpretabile.
*/
public function geometria(): ?array
{
$decoded = json_decode($this->geojson, true);
return is_array($decoded) ? $decoded : null;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class TerritorioPdfVersione extends Model
{
protected $table = 'territorio_pdf_versioni';
protected $fillable = [
'territorio_id',
'pdf_path',
'thumbnail_path',
'origine',
'sostituito_il',
];
protected function casts(): array
{
return [
'sostituito_il' => 'datetime',
];
}
public function territorio()
{
return $this->belongsTo(Territorio::class, 'territorio_id');
}
}

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