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:
@@ -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;
|
||||
}
|
||||
|
||||
private function runTippecanoe(string $geojson, string $outputDir, string $layer, SymfonyStyle $io): void
|
||||
return $tmpFile;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user