fix: streaming GeoJSON pour éviter l'OOM + fenêtre 5 jours + simplification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-22 23:00:25 -04:00
parent 493fa35d54
commit 4d33056280

View File

@@ -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<array<string, mixed>> $rows
* @param array<string, callable(array<string, mixed>): 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<array<string,mixed>> $rows
* @param callable(array<string,mixed>): array<string,mixed> $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);
}
}
}