title('Génération vector tiles'); $tilesDir = $this->projectDir . '/public/tiles'; // Observations $io->section('Observations'); $geojson = $this->queryObservationsGeoJSON(); if ($geojson !== null) { $this->runTippecanoe($geojson, $tilesDir . '/observations', 'observations', $io); } else { $io->warning('Aucune observation récente — tiles non générées.'); } // Prévisions par horizon foreach (self::TILE_HORIZONS as $horizon) { $io->section("Prévisions H+{$horizon}"); $geojson = $this->queryForecastsGeoJSON($horizon); if ($geojson !== null) { $this->runTippecanoe($geojson, $tilesDir . '/forecasts/h' . $horizon, 'forecasts', $io); } else { $io->warning("Aucune prévision H+{$horizon} — tiles non générées."); } } $io->success('Génération terminée.'); return Command::SUCCESS; } private function queryObservationsGeoJSON(): ?string { $rows = $this->connection->fetchAllAssociative( "SELECT ST_AsGeoJSON(geometry) AS geometry, COALESCE(afai_mean, 0) AS afai FROM sargassum_observation WHERE detected_at >= NOW() - INTERVAL '30 days' AND coverage_area > 0 ORDER BY detected_at DESC" ); return $this->buildFeatureCollection($rows, [ 'afai' => static fn($r) => (float) $r['afai'], ]); } private function queryForecastsGeoJSON(int $horizon): ?string { $rows = $this->connection->fetchAllAssociative( "SELECT ST_AsGeoJSON(geometry) AS geometry, confidence FROM sargassum_forecast WHERE time_horizon = :horizon AND computed_at >= NOW() - INTERVAL '30 days' ORDER BY computed_at DESC", ['horizon' => $horizon] ); return $this->buildFeatureCollection($rows, [ 'confidence' => static fn($r) => (float) $r['confidence'], ]); } /** * @param array> $rows * @param array): mixed> $props */ private function buildFeatureCollection(array $rows, array $props): ?string { if (empty($rows)) { return null; } $features = []; foreach ($rows as $row) { $properties = []; foreach ($props as $key => $fn) { $properties[$key] = $fn($row); } $features[] = [ 'type' => 'Feature', 'geometry' => json_decode($row['geometry'], true, 512, JSON_THROW_ON_ERROR), 'properties' => $properties, ]; } return json_encode( ['type' => 'FeatureCollection', 'features' => $features], JSON_THROW_ON_ERROR ); } private function runTippecanoe(string $geojson, string $outputDir, string $layer, SymfonyStyle $io): void { $parentDir = dirname($outputDir); if (!is_dir($parentDir)) { mkdir($parentDir, 0755, true); } $tmpFile = sys_get_temp_dir() . '/tiles_' . uniqid() . '.geojson'; file_put_contents($tmpFile, $geojson); try { $cmd = sprintf( 'tippecanoe -e %s --force --no-tile-compression --layer=%s -Z%d -z%d --drop-densest-as-needed --quiet %s 2>&1', escapeshellarg($outputDir), escapeshellarg($layer), self::MIN_ZOOM, self::MAX_ZOOM, escapeshellarg($tmpFile) ); exec($cmd, $cmdOutput, $exitCode); if ($exitCode !== 0) { $errorMsg = implode("\n", $cmdOutput); $this->logger->error('tippecanoe failed', ['output' => $errorMsg, 'dir' => $outputDir]); $io->error("Tippecanoe a échoué : {$errorMsg}"); } else { $io->success("→ {$outputDir}"); } } finally { @unlink($tmpFile); } } }