diff --git a/backend/src/Command/GenerateTilesCommand.php b/backend/src/Command/GenerateTilesCommand.php index a33d024..cd00150 100644 --- a/backend/src/Command/GenerateTilesCommand.php +++ b/backend/src/Command/GenerateTilesCommand.php @@ -38,9 +38,9 @@ class GenerateTilesCommand extends Command // Observations $io->section('Observations'); - $geojson = $this->queryObservationsGeoJSON(); - if ($geojson !== null) { - $this->runTippecanoe($geojson, $tilesDir . '/observations', 'observations', $io); + $geojsonFile = $this->queryObservationsGeoJSON(); + if ($geojsonFile !== null) { + $this->runTippecanoe($geojsonFile, $tilesDir . '/observations', 'observations', $io); } else { $io->warning('Aucune observation récente — tiles non générées.'); } @@ -48,9 +48,9 @@ class GenerateTilesCommand extends Command // 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); + $geojsonFile = $this->queryForecastsGeoJSON($horizon); + if ($geojsonFile !== null) { + $this->runTippecanoe($geojsonFile, $tilesDir . '/forecasts/h' . $horizon, 'forecasts', $io); } else { $io->warning("Aucune prévision H+{$horizon} — tiles non générées."); } @@ -62,81 +62,99 @@ class GenerateTilesCommand extends Command private function queryObservationsGeoJSON(): ?string { - $rows = $this->connection->fetchAllAssociative( - "SELECT ST_AsGeoJSON(geometry) AS geometry, + // ST_SimplifyPreserveTopology réduit le nombre de coordonnées avant export + // (tolérance 0.003° ≈ 300m, suffisant pour zoom 5-12). + $result = $this->connection->executeQuery( + "SELECT ST_AsGeoJSON(ST_SimplifyPreserveTopology(geometry, 0.003)) AS geometry, COALESCE(afai_mean, 0) AS afai, source, confidence, ROUND(COALESCE(coverage_area, 0)::numeric, 1) AS coverage_km2 FROM sargassum_observation - WHERE detected_at >= NOW() - INTERVAL '30 days' + WHERE detected_at >= NOW() - INTERVAL '5 days' AND coverage_area > 0 ORDER BY detected_at DESC" ); - return $this->buildFeatureCollection($rows, [ - 'afai' => static fn($r) => (float) $r['afai'], - 'source' => static fn($r) => $r['source'], // 'Sentinel-2' | 'Sentinel-3' - 'confidence' => static fn($r) => (float) $r['confidence'], - 'coverage_km2' => static fn($r) => (float) $r['coverage_km2'], + return $this->streamToGeoJSON($result->iterateAssociative(), static fn($r) => [ + 'afai' => (float) $r['afai'], + 'source' => $r['source'], + 'confidence' => (float) $r['confidence'], + 'coverage_km2' => (float) $r['coverage_km2'], ]); } private function queryForecastsGeoJSON(int $horizon): ?string { - $rows = $this->connection->fetchAllAssociative( - "SELECT ST_AsGeoJSON(geometry) AS geometry, confidence + $result = $this->connection->executeQuery( + "SELECT ST_AsGeoJSON(ST_SimplifyPreserveTopology(geometry, 0.003)) AS geometry, + confidence FROM sargassum_forecast WHERE time_horizon = :horizon - AND computed_at >= NOW() - INTERVAL '30 days' + AND computed_at >= NOW() - INTERVAL '5 days' ORDER BY computed_at DESC", ['horizon' => $horizon] ); - return $this->buildFeatureCollection($rows, [ - 'confidence' => static fn($r) => (float) $r['confidence'], + return $this->streamToGeoJSON($result->iterateAssociative(), static fn($r) => [ + 'confidence' => (float) $r['confidence'], ]); } /** - * @param array> $rows - * @param array): mixed> $props + * Écrit un FeatureCollection GeoJSON ligne par ligne dans un fichier temporaire. + * Évite de charger toutes les géométries en mémoire PHP simultanément. + * + * @param \Traversable> $rows + * @param callable(array): array $propsBuilder */ - private function buildFeatureCollection(array $rows, array $props): ?string + private function streamToGeoJSON(\Traversable $rows, callable $propsBuilder): ?string { - if (empty($rows)) { + $tmpFile = sys_get_temp_dir() . '/tiles_' . uniqid() . '.geojson'; + $fh = fopen($tmpFile, 'w'); + if ($fh === false) { return null; } - $features = []; + fwrite($fh, '{"type":"FeatureCollection","features":['); + $count = 0; + foreach ($rows as $row) { - $properties = []; - foreach ($props as $key => $fn) { - $properties[$key] = $fn($row); + $geom = $row['geometry']; + if ($geom === null) { + continue; } - $features[] = [ + $feature = json_encode([ 'type' => 'Feature', - 'geometry' => json_decode($row['geometry'], true, 512, JSON_THROW_ON_ERROR), - 'properties' => $properties, - ]; + 'geometry' => json_decode($geom, true, 512, JSON_THROW_ON_ERROR), + 'properties' => $propsBuilder($row), + ], JSON_THROW_ON_ERROR); + + if ($count > 0) { + fwrite($fh, ','); + } + fwrite($fh, $feature); + ++$count; } - return json_encode( - ['type' => 'FeatureCollection', 'features' => $features], - JSON_THROW_ON_ERROR - ); + fwrite($fh, ']}'); + fclose($fh); + + if ($count === 0) { + @unlink($tmpFile); + return null; + } + + return $tmpFile; } - private function runTippecanoe(string $geojson, string $outputDir, string $layer, SymfonyStyle $io): void + private function runTippecanoe(string $geojsonFile, 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', @@ -144,7 +162,7 @@ class GenerateTilesCommand extends Command escapeshellarg($layer), self::MIN_ZOOM, self::MAX_ZOOM, - escapeshellarg($tmpFile) + escapeshellarg($geojsonFile) ); exec($cmd, $cmdOutput, $exitCode); @@ -157,7 +175,7 @@ class GenerateTilesCommand extends Command $io->success("→ {$outputDir}"); } } finally { - @unlink($tmpFile); + @unlink($geojsonFile); } } }