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
|
// Observations
|
||||||
$io->section('Observations');
|
$io->section('Observations');
|
||||||
$geojson = $this->queryObservationsGeoJSON();
|
$geojsonFile = $this->queryObservationsGeoJSON();
|
||||||
if ($geojson !== null) {
|
if ($geojsonFile !== null) {
|
||||||
$this->runTippecanoe($geojson, $tilesDir . '/observations', 'observations', $io);
|
$this->runTippecanoe($geojsonFile, $tilesDir . '/observations', 'observations', $io);
|
||||||
} else {
|
} else {
|
||||||
$io->warning('Aucune observation récente — tiles non générées.');
|
$io->warning('Aucune observation récente — tiles non générées.');
|
||||||
}
|
}
|
||||||
@@ -48,9 +48,9 @@ class GenerateTilesCommand extends Command
|
|||||||
// Prévisions par horizon
|
// Prévisions par horizon
|
||||||
foreach (self::TILE_HORIZONS as $horizon) {
|
foreach (self::TILE_HORIZONS as $horizon) {
|
||||||
$io->section("Prévisions H+{$horizon}");
|
$io->section("Prévisions H+{$horizon}");
|
||||||
$geojson = $this->queryForecastsGeoJSON($horizon);
|
$geojsonFile = $this->queryForecastsGeoJSON($horizon);
|
||||||
if ($geojson !== null) {
|
if ($geojsonFile !== null) {
|
||||||
$this->runTippecanoe($geojson, $tilesDir . '/forecasts/h' . $horizon, 'forecasts', $io);
|
$this->runTippecanoe($geojsonFile, $tilesDir . '/forecasts/h' . $horizon, 'forecasts', $io);
|
||||||
} else {
|
} else {
|
||||||
$io->warning("Aucune prévision H+{$horizon} — tiles non générées.");
|
$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
|
private function queryObservationsGeoJSON(): ?string
|
||||||
{
|
{
|
||||||
$rows = $this->connection->fetchAllAssociative(
|
// ST_SimplifyPreserveTopology réduit le nombre de coordonnées avant export
|
||||||
"SELECT ST_AsGeoJSON(geometry) AS geometry,
|
// (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,
|
COALESCE(afai_mean, 0) AS afai,
|
||||||
source,
|
source,
|
||||||
confidence,
|
confidence,
|
||||||
ROUND(COALESCE(coverage_area, 0)::numeric, 1) AS coverage_km2
|
ROUND(COALESCE(coverage_area, 0)::numeric, 1) AS coverage_km2
|
||||||
FROM sargassum_observation
|
FROM sargassum_observation
|
||||||
WHERE detected_at >= NOW() - INTERVAL '30 days'
|
WHERE detected_at >= NOW() - INTERVAL '5 days'
|
||||||
AND coverage_area > 0
|
AND coverage_area > 0
|
||||||
ORDER BY detected_at DESC"
|
ORDER BY detected_at DESC"
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->buildFeatureCollection($rows, [
|
return $this->streamToGeoJSON($result->iterateAssociative(), static fn($r) => [
|
||||||
'afai' => static fn($r) => (float) $r['afai'],
|
'afai' => (float) $r['afai'],
|
||||||
'source' => static fn($r) => $r['source'], // 'Sentinel-2' | 'Sentinel-3'
|
'source' => $r['source'],
|
||||||
'confidence' => static fn($r) => (float) $r['confidence'],
|
'confidence' => (float) $r['confidence'],
|
||||||
'coverage_km2' => static fn($r) => (float) $r['coverage_km2'],
|
'coverage_km2' => (float) $r['coverage_km2'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function queryForecastsGeoJSON(int $horizon): ?string
|
private function queryForecastsGeoJSON(int $horizon): ?string
|
||||||
{
|
{
|
||||||
$rows = $this->connection->fetchAllAssociative(
|
$result = $this->connection->executeQuery(
|
||||||
"SELECT ST_AsGeoJSON(geometry) AS geometry, confidence
|
"SELECT ST_AsGeoJSON(ST_SimplifyPreserveTopology(geometry, 0.003)) AS geometry,
|
||||||
|
confidence
|
||||||
FROM sargassum_forecast
|
FROM sargassum_forecast
|
||||||
WHERE time_horizon = :horizon
|
WHERE time_horizon = :horizon
|
||||||
AND computed_at >= NOW() - INTERVAL '30 days'
|
AND computed_at >= NOW() - INTERVAL '5 days'
|
||||||
ORDER BY computed_at DESC",
|
ORDER BY computed_at DESC",
|
||||||
['horizon' => $horizon]
|
['horizon' => $horizon]
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->buildFeatureCollection($rows, [
|
return $this->streamToGeoJSON($result->iterateAssociative(), static fn($r) => [
|
||||||
'confidence' => static fn($r) => (float) $r['confidence'],
|
'confidence' => (float) $r['confidence'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<array<string, mixed>> $rows
|
* Écrit un FeatureCollection GeoJSON ligne par ligne dans un fichier temporaire.
|
||||||
* @param array<string, callable(array<string, mixed>): mixed> $props
|
* É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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$features = [];
|
fwrite($fh, '{"type":"FeatureCollection","features":[');
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$properties = [];
|
$geom = $row['geometry'];
|
||||||
foreach ($props as $key => $fn) {
|
if ($geom === null) {
|
||||||
$properties[$key] = $fn($row);
|
continue;
|
||||||
}
|
}
|
||||||
$features[] = [
|
$feature = json_encode([
|
||||||
'type' => 'Feature',
|
'type' => 'Feature',
|
||||||
'geometry' => json_decode($row['geometry'], true, 512, JSON_THROW_ON_ERROR),
|
'geometry' => json_decode($geom, true, 512, JSON_THROW_ON_ERROR),
|
||||||
'properties' => $properties,
|
'properties' => $propsBuilder($row),
|
||||||
];
|
], JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
if ($count > 0) {
|
||||||
|
fwrite($fh, ',');
|
||||||
|
}
|
||||||
|
fwrite($fh, $feature);
|
||||||
|
++$count;
|
||||||
}
|
}
|
||||||
|
|
||||||
return json_encode(
|
fwrite($fh, ']}');
|
||||||
['type' => 'FeatureCollection', 'features' => $features],
|
fclose($fh);
|
||||||
JSON_THROW_ON_ERROR
|
|
||||||
);
|
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);
|
$parentDir = dirname($outputDir);
|
||||||
if (!is_dir($parentDir)) {
|
if (!is_dir($parentDir)) {
|
||||||
mkdir($parentDir, 0755, true);
|
mkdir($parentDir, 0755, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tmpFile = sys_get_temp_dir() . '/tiles_' . uniqid() . '.geojson';
|
|
||||||
file_put_contents($tmpFile, $geojson);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$cmd = sprintf(
|
$cmd = sprintf(
|
||||||
'tippecanoe -e %s --force --no-tile-compression --layer=%s -Z%d -z%d --drop-densest-as-needed --quiet %s 2>&1',
|
'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),
|
escapeshellarg($layer),
|
||||||
self::MIN_ZOOM,
|
self::MIN_ZOOM,
|
||||||
self::MAX_ZOOM,
|
self::MAX_ZOOM,
|
||||||
escapeshellarg($tmpFile)
|
escapeshellarg($geojsonFile)
|
||||||
);
|
);
|
||||||
|
|
||||||
exec($cmd, $cmdOutput, $exitCode);
|
exec($cmd, $cmdOutput, $exitCode);
|
||||||
@@ -157,7 +175,7 @@ class GenerateTilesCommand extends Command
|
|||||||
$io->success("→ {$outputDir}");
|
$io->success("→ {$outputDir}");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@unlink($tmpFile);
|
@unlink($geojsonFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user