Files
radarsargasses/backend/src/Command/GenerateTilesCommand.php
Gwadaking 08a4754b26 fix: désactiver la compression gzip de Tippecanoe (MapLibre v5)
Tippecanoe compresse les tiles .pbf par défaut. Caddy ne gzip pas
application/x-protobuf (pas dans sa liste par défaut), donc aucun
header Content-Encoding n'est émis. MapLibre v5 a supprimé la
détection auto des magic bytes gzip → erreur "Unable to parse tile".

--no-tile-compression génère des tiles protobuf bruts, lisibles
directement par MapLibre sans header supplémentaire.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:09:02 -04:00

157 lines
5.2 KiB
PHP

<?php
namespace App\Command;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
#[AsCommand(
name: 'app:generate-tiles',
description: 'Génère les vector tiles (Tippecanoe) pour observations et prédictions',
)]
class GenerateTilesCommand extends Command
{
private const TILE_HORIZONS = [6, 12, 24, 48];
private const MIN_ZOOM = 5;
private const MAX_ZOOM = 12;
public function __construct(
private Connection $connection,
#[Autowire('%kernel.project_dir%')] private string $projectDir,
private LoggerInterface $logger,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->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<array<string, mixed>> $rows
* @param array<string, callable(array<string, mixed>): 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);
}
}
}